/** * RosenCharts - Vanilla JS/D3 Chart Components for wa-sink * * Migrated from React/TypeScript to browser-compatible vanilla JavaScript. * Uses D3.js for data manipulation and SVG rendering. * Styled with Web Awesome CSS variables. * * Charts included: * 1. CandleChart - OHLC price history with zoom/crosshair * 2. AreaChartStacked - Multi-series stacked area for total asset value * 3. BubbleChart - Circle packing for market cap dominance * 4. AreaChartSemiFilled - Line/area chart for individual asset performance */ // ============================================================================= // UTILITY FUNCTIONS // ============================================================================= const RosenCharts = { // Color mappings from Tailwind to Web Awesome CSS variables colors: { success: 'var(--wa-color-success)', danger: 'var(--wa-color-danger)', primary: 'var(--wa-color-primary)', neutral400: 'var(--wa-color-neutral-400)', neutral500: 'var(--wa-color-neutral-500)', neutral600: 'var(--wa-color-neutral-600)', surfaceAlt: 'var(--wa-color-surface-alt)', surface: 'var(--wa-color-surface)', // Chart-specific palette fuchsia: { from: '#f0abfc', to: '#e879f9' }, purple: { from: '#c4b5fd', to: '#a855f7' }, blue: { from: '#93c5fd', to: '#3b82f6' }, sky: { from: '#bae6fd', to: '#38bdf8' }, orange: { from: '#fed7aa', to: '#fb923c' }, yellow: { from: '#fef08a', to: '#facc15' }, emerald: { from: '#6ee7b7', to: '#10b981' }, red: { from: '#fca5a5', to: '#ef4444' } }, // Format number with commas formatNumber(value, decimals = 2) { return new Intl.NumberFormat('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }).format(value); }, // Format currency formatCurrency(value) { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value); }, // Check if desktop isDesktop() { return window.innerWidth > 1024; }, // Create SVG element with proper namespace createSVG(tag, attrs = {}) { const el = document.createElementNS('http://www.w3.org/2000/svg', tag); Object.entries(attrs).forEach(([key, value]) => { el.setAttribute(key, value); }); return el; }, // Create HTML element createElement(tag, attrs = {}, children = []) { const el = document.createElement(tag); Object.entries(attrs).forEach(([key, value]) => { if (key === 'style' && typeof value === 'object') { Object.assign(el.style, value); } else if (key === 'className') { el.className = value; } else if (key.startsWith('data')) { el.setAttribute(key.replace(/([A-Z])/g, '-$1').toLowerCase(), value); } else { el.setAttribute(key, value); } }); children.forEach(child => { if (typeof child === 'string') { el.appendChild(document.createTextNode(child)); } else if (child) { el.appendChild(child); } }); return el; } }; // ============================================================================= // 1. CANDLE CHART - OHLC Price History with Zoom and Crosshair // ============================================================================= class CandleChart { constructor(container, options = {}) { this.container = typeof container === 'string' ? document.querySelector(container) : container; this.options = { marginTop: 10, marginRight: 60, marginBottom: 56, marginLeft: 30, height: 288, // h-72 = 18rem = 288px ...options }; this.data = options.data || []; this.zoomLevel = 1; this.visibleRange = { start: 0, end: this.data.length - 1 }; this.mousePosition = null; this.hoverData = null; this.init(); } init() { this.container.innerHTML = ''; this.container.style.position = 'relative'; // Create wrapper with @container for responsive queries this.wrapper = RosenCharts.createElement('div', { className: 'candle-chart-wrapper', style: { position: 'relative' } }); // Create OHLC Legend this.legend = RosenCharts.createElement('div', { className: 'candle-legend', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px', padding: '4px 32px', background: 'var(--wa-color-surface-alt)', borderRadius: 'var(--wa-radius-s)', fontSize: 'var(--wa-font-size-xs)' } }); this.updateLegend(); // Create chart area this.chartArea = RosenCharts.createElement('div', { className: 'candle-chart-area', style: { position: 'relative', height: `${this.options.height}px`, width: '100%' } }); // Set CSS custom properties for margins this.chartArea.style.setProperty('--marginTop', `${this.options.marginTop}px`); this.chartArea.style.setProperty('--marginRight', `${this.options.marginRight}px`); this.chartArea.style.setProperty('--marginBottom', `${this.options.marginBottom}px`); this.chartArea.style.setProperty('--marginLeft', `${this.options.marginLeft}px`); this.wrapper.appendChild(this.legend); this.wrapper.appendChild(this.chartArea); this.container.appendChild(this.wrapper); this.render(); this.attachEvents(); } setData(data) { this.data = data; this.visibleRange = { start: 0, end: data.length - 1 }; this.zoomLevel = 1; this.render(); } getVisibleData() { return this.data.slice(this.visibleRange.start, this.visibleRange.end + 1); } createScales() { const visibleData = this.getVisibleData(); // X Scale - band scale for discrete candles this.xScale = d3.scaleBand() .domain(visibleData.map(d => d.date)) .range([0, 100]) .padding(0.3); // Y Scale - linear scale for price values const yMin = d3.min(visibleData, d => d.low) * 0.995; const yMax = d3.max(visibleData, d => d.high) * 1.005; this.yScale = d3.scaleLinear() .domain([yMin, yMax]) .range([100, 0]); } updateLegend() { const visibleData = this.getVisibleData(); if (this.hoverData) { const changePercent = ((this.hoverData.close - this.hoverData.open) / this.hoverData.open * 100).toFixed(2); const isPositive = this.hoverData.close > this.hoverData.open; this.legend.innerHTML = `
Zoom: ${this.zoomLevel.toFixed(1)}x | Showing ${visibleData.length} of ${this.data.length} candles
O: ${this.hoverData.open.toFixed(2)} H: ${this.hoverData.high.toFixed(2)} L: ${this.hoverData.low.toFixed(2)} C: ${this.hoverData.close.toFixed(2)} ${changePercent}%
`; } else { this.legend.innerHTML = `
Hover over chart to see OHLC data
`; } } render() { this.createScales(); const visibleData = this.getVisibleData(); // Clear and rebuild chart area this.chartArea.innerHTML = ''; // Y-axis (right side) const yAxisContainer = RosenCharts.createElement('div', { style: { position: 'absolute', height: `calc(100% - ${this.options.marginTop}px - ${this.options.marginBottom}px)`, transform: `translateY(${this.options.marginTop}px)`, right: `calc(${this.options.marginRight}px - 1rem)`, overflow: 'visible' } }); const yTicks = this.yScale.ticks(6); yTicks.forEach(value => { const label = RosenCharts.createElement('div', { style: { position: 'absolute', right: '0%', top: `${this.yScale(value)}%`, transform: 'translateY(-50%)', fontSize: 'var(--wa-font-size-xs)', fontVariantNumeric: 'tabular-nums', color: 'var(--wa-color-neutral-400)', width: '100%', textAlign: 'right' } }, [value.toFixed(2)]); yAxisContainer.appendChild(label); }); // Main chart container const chartInner = RosenCharts.createElement('div', { className: 'chart-inner', style: { position: 'absolute', inset: 0, height: `calc(100% - ${this.options.marginTop}px - ${this.options.marginBottom}px)`, width: `calc(100% - ${this.options.marginLeft}px - ${this.options.marginRight}px)`, transform: `translate(${this.options.marginLeft}px, ${this.options.marginTop}px)`, overflow: 'visible' } }); // SVG for grid lines and wicks const svg = RosenCharts.createSVG('svg', { viewBox: '0 0 100 100', preserveAspectRatio: 'none', style: 'overflow: visible; width: 100%; height: 100%;' }); // Grid lines yTicks.forEach(value => { const line = RosenCharts.createSVG('line', { x1: 0, x2: 100, y1: this.yScale(value), y2: this.yScale(value), stroke: 'var(--wa-color-neutral-200)', 'stroke-dasharray': '6,5', 'stroke-width': 0.5, 'vector-effect': 'non-scaling-stroke' }); svg.appendChild(line); }); // Wicks (high-low lines) visibleData.forEach(d => { const barX = this.xScale(d.date) + this.xScale.bandwidth() / 2; const line = RosenCharts.createSVG('line', { x1: barX, y1: this.yScale(d.high), x2: barX, y2: this.yScale(d.low), stroke: 'var(--wa-color-neutral-300)', 'stroke-width': 1, 'vector-effect': 'non-scaling-stroke' }); svg.appendChild(line); }); chartInner.appendChild(svg); // X-axis labels const skipFactor = Math.max(1, Math.floor(RosenCharts.isDesktop() ? 12 : 24 / this.zoomLevel)); visibleData.forEach((entry, i) => { if (i % skipFactor !== 0) return; const xPos = this.xScale(entry.date) + this.xScale.bandwidth() / 2; const date = new Date(entry.date); const timeLabel = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const dateLabel = `${date.getMonth() + 1}/${date.getDate()}`; const label = RosenCharts.createElement('div', { style: { position: 'absolute', overflow: 'visible', color: 'var(--wa-color-neutral-400)', pointerEvents: 'none', left: `${xPos}%`, top: '100%', transform: 'rotate(45deg) translateX(4px) translateY(8px)' } }); const text = RosenCharts.createElement('div', { style: { position: 'absolute', fontSize: 'var(--wa-font-size-xs)', transform: 'translateY(-50%)', whiteSpace: 'nowrap' } }, [`${dateLabel} - `, RosenCharts.createElement('strong', {}, [timeLabel])]); label.appendChild(text); chartInner.appendChild(label); }); // Candle bodies (open-close bars) visibleData.forEach(d => { const barWidth = this.xScale.bandwidth(); const barHeight = Math.abs(this.yScale(d.open) - this.yScale(d.close)); const barX = this.xScale(d.date); const barY = this.yScale(Math.max(d.open, d.close)); const isUp = d.close > d.open; const bar = RosenCharts.createElement('div', { style: { position: 'absolute', width: `${barWidth}%`, height: `${barHeight}%`, left: `${barX}%`, top: `${barY}%`, background: isUp ? 'linear-gradient(to bottom, #6ee7b7, #10b981)' : 'linear-gradient(to bottom, #fca5a5, #ef4444)', borderRadius: '1px' } }); chartInner.appendChild(bar); }); // Crosshair container (will be updated on mouse move) this.crosshairContainer = RosenCharts.createElement('div', { className: 'crosshair-container', style: { display: 'none' } }); chartInner.appendChild(this.crosshairContainer); this.chartArea.appendChild(yAxisContainer); this.chartArea.appendChild(chartInner); this.chartInner = chartInner; } updateCrosshair() { if (!this.mousePosition) { this.crosshairContainer.style.display = 'none'; return; } this.crosshairContainer.style.display = 'block'; this.crosshairContainer.innerHTML = ''; // Vertical line const vLine = RosenCharts.createElement('div', { style: { position: 'absolute', top: 0, height: '100%', width: '1px', borderLeft: '1px dashed var(--wa-color-neutral-300)', pointerEvents: 'none', zIndex: 20, left: `${this.mousePosition.x}%` } }); // Horizontal line const hLine = RosenCharts.createElement('div', { style: { position: 'absolute', left: 0, width: '100%', height: '1px', borderTop: '1px dashed var(--wa-color-neutral-300)', pointerEvents: 'none', zIndex: 20, top: `${this.mousePosition.y}%` } }); // Y-axis value label const yValue = this.yScale.invert(this.mousePosition.y); const yLabel = RosenCharts.createElement('div', { style: { position: 'absolute', right: 0, transform: 'translateX(4px) translateY(-50%)', background: 'var(--wa-color-surface-alt)', padding: '0 4px', fontSize: 'var(--wa-font-size-xs)', borderRadius: 'var(--wa-radius-s)', pointerEvents: 'none', zIndex: 20, top: `${this.mousePosition.y}%` } }, [yValue.toFixed(2)]); this.crosshairContainer.appendChild(vLine); this.crosshairContainer.appendChild(hLine); this.crosshairContainer.appendChild(yLabel); // Date label at bottom if (this.hoverData) { const dateLabel = RosenCharts.createElement('div', { style: { position: 'absolute', bottom: 0, transform: 'translateY(24px) translateX(-50%)', background: 'var(--wa-color-surface-alt)', padding: '4px 8px', fontSize: 'var(--wa-font-size-xs)', borderRadius: 'var(--wa-radius-s)', pointerEvents: 'none', zIndex: 20, fontWeight: 500, left: `${this.mousePosition.x}%` } }, [this.hoverData.date]); this.crosshairContainer.appendChild(dateLabel); } } attachEvents() { // Mouse move for crosshair this.chartArea.addEventListener('mousemove', (e) => { const rect = this.chartInner.getBoundingClientRect(); const x = ((e.clientX - rect.left) / rect.width) * 100; const y = ((e.clientY - rect.top) / rect.height) * 100; this.mousePosition = { x, y }; // Find closest data point const visibleData = this.getVisibleData(); const xPos = (x / 100) * rect.width; let closestIndex = 0; let minDistance = Infinity; visibleData.forEach((d, i) => { const barX = (this.xScale(d.date) / 100) * rect.width + ((this.xScale.bandwidth() / 100) * rect.width) / 2; const distance = Math.abs(barX - xPos); if (distance < minDistance) { minDistance = distance; closestIndex = i; } }); this.hoverData = visibleData[closestIndex]; this.updateLegend(); this.updateCrosshair(); }); // Mouse leave this.chartArea.addEventListener('mouseleave', () => { this.mousePosition = null; this.hoverData = null; this.updateLegend(); this.updateCrosshair(); }); // Wheel for zoom this.chartArea.addEventListener('wheel', (e) => { e.preventDefault(); const zoomIn = e.deltaY < 0; const zoomFactor = 1.04; const newZoomLevel = zoomIn ? Math.min(this.zoomLevel * zoomFactor, 20) : Math.max(this.zoomLevel / zoomFactor, 1); const visibleCount = Math.max(5, Math.floor(this.data.length / newZoomLevel)); const newEnd = this.data.length - 1; const newStart = Math.max(0, newEnd - visibleCount + 1); this.zoomLevel = newZoomLevel; this.visibleRange = { start: newStart, end: newEnd }; this.render(); this.updateLegend(); }, { passive: false }); } } // ============================================================================= // 2. STACKED AREA CHART - Total Asset Value Over Time // ============================================================================= class StackedAreaChart { constructor(container, options = {}) { this.container = typeof container === 'string' ? document.querySelector(container) : container; this.options = { height: 256, // h-64 colors: [ { from: '#f5d0fe', to: '#e879f9', bg: '#e879f9' }, // fuchsia { from: '#c4b5fd', to: '#a855f7', bg: '#a855f7' }, // purple { from: '#bfdbfe', to: '#3b82f6', bg: '#3b82f6' }, // blue { from: '#bae6fd', to: '#38bdf8', bg: '#38bdf8' }, // sky { from: '#fed7aa', to: '#fb923c', bg: '#fb923c' } // orange ], ...options }; this.data = options.data || []; this.init(); } init() { this.container.innerHTML = ''; this.container.style.position = 'relative'; this.container.classList.add('group'); this.processData(); this.render(); } setData(data) { this.data = data; this.processData(); this.render(); } processData() { if (!this.data.length) return; const parseDate = d3.utcParse('%Y-%m-%d'); // Get unique industries/categories this.industries = Array.from(new Set(this.data.map(d => d.industry))); // Group by date this.groupedData = Array.from( d3.group(this.data, d => d.date), ([date, values]) => { const obj = { date: parseDate(date.split('T')[0]) }; values.forEach(val => { obj[val.industry] = val.unemployed || val.value || 0; }); return obj; } ); // Create stacked series this.series = d3.stack() .keys(this.industries)(this.groupedData); } render() { if (!this.series || !this.series.length) return; this.container.innerHTML = ''; // Legend const legendContainer = RosenCharts.createElement('div', { style: { display: 'flex', textAlign: 'center', gap: '16px', fontSize: 'var(--wa-font-size-xs)', height: 'fit-content', overflowX: 'auto', padding: '0 40px', marginBottom: '32px' } }); this.industries.slice().reverse().forEach((industry, i) => { const colorIndex = this.industries.length - 1 - i; const color = this.options.colors[colorIndex % this.options.colors.length]; const item = RosenCharts.createElement('div', { style: { display: 'flex', gap: '6px', alignItems: 'center' } }); const dot = RosenCharts.createElement('div', { style: { width: '4px', height: '100%', borderRadius: '9999px', background: color.bg } }); item.appendChild(dot); item.appendChild(document.createTextNode(industry)); legendContainer.appendChild(item); }); // Chart container const chartContainer = RosenCharts.createElement('div', { style: { position: 'relative', height: `${this.options.height}px`, width: '100%' } }); // Scales const xScale = d3.scaleUtc() .domain(d3.extent(this.groupedData, d => d.date)) .range([0, 100]); const yScale = d3.scaleLinear() .domain([0, d3.max(this.series, d => d3.max(d, d => d[1])) || 0]) .rangeRound([100, 0]); // Area generator const area = d3.area() .x(d => xScale(d.data.date)) .y0(d => yScale(d[0])) .y1(d => yScale(d[1])) .curve(d3.curveMonotoneX); // SVG const svg = RosenCharts.createSVG('svg', { style: 'position: absolute; inset: 0; z-index: 10; height: 100%; width: 100%; overflow: visible;' }); const innerSvg = RosenCharts.createSVG('svg', { viewBox: '0 0 100 100', preserveAspectRatio: 'none', style: 'overflow: visible;' }); // Gradients const defs = RosenCharts.createSVG('defs'); this.series.forEach((_, i) => { const color = this.options.colors[i % this.options.colors.length]; const gradient = RosenCharts.createSVG('linearGradient', { id: `stacked-area-gradient-${i}`, x1: 0, x2: 0.5, y1: 0.25, y2: 1 }); const stop1 = RosenCharts.createSVG('stop', { offset: '0%', 'stop-color': color.from }); const stop2 = RosenCharts.createSVG('stop', { offset: '80%', 'stop-color': color.to }); gradient.appendChild(stop1); gradient.appendChild(stop2); defs.appendChild(gradient); }); innerSvg.appendChild(defs); // Areas this.series.forEach((layer, layerIndex) => { const path = RosenCharts.createSVG('path', { fill: `url(#stacked-area-gradient-${layerIndex})`, d: area(layer), stroke: '#ccc', 'stroke-width': '0.05' }); innerSvg.appendChild(path); }); svg.appendChild(innerSvg); // X-axis const xAxisSvg = RosenCharts.createSVG('svg', { style: ` position: absolute; inset: 0; height: 100%; width: 100%; transform: translateY(-12px); overflow: visible; z-index: 0; opacity: 0; transition: opacity 0.3s; `, class: 'x-axis-svg' }); this.series[0].forEach((day, i) => { if (i % 2 === 0) return; const date = day.data.date; const text = RosenCharts.createSVG('text', { x: `${xScale(date)}%`, y: '100%', 'text-anchor': i === 0 ? 'start' : i === this.series[0].length - 1 ? 'end' : 'middle', fill: 'var(--wa-color-neutral-500)', style: 'font-size: var(--wa-font-size-xs);' }); text.textContent = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); xAxisSvg.appendChild(text); }); // Y-axis const yAxisSvg = RosenCharts.createSVG('svg', { style: ` position: absolute; inset: 0; width: 100%; height: 100%; transform: translateX(90%); overflow: visible; z-index: 20; pointer-events: none; opacity: 0; transition: opacity 0.3s; `, class: 'y-axis-svg' }); yScale.ticks(8).forEach((value, i) => { if (i === 0) return; const text = RosenCharts.createSVG('text', { y: `${yScale(value)}%`, 'alignment-baseline': 'middle', 'text-anchor': 'start', fill: 'var(--wa-color-neutral-600)', style: 'font-size: var(--wa-font-size-xs);' }); text.textContent = value; yAxisSvg.appendChild(text); }); chartContainer.appendChild(svg); chartContainer.appendChild(xAxisSvg); chartContainer.appendChild(yAxisSvg); this.container.appendChild(legendContainer); this.container.appendChild(chartContainer); // Hover effect for axes this.container.addEventListener('mouseenter', () => { chartContainer.querySelector('.x-axis-svg').style.opacity = '1'; chartContainer.querySelector('.y-axis-svg').style.opacity = '1'; }); this.container.addEventListener('mouseleave', () => { chartContainer.querySelector('.x-axis-svg').style.opacity = '0'; chartContainer.querySelector('.y-axis-svg').style.opacity = '0'; }); } } // ============================================================================= // 3. BUBBLE CHART - Market Cap Dominance // ============================================================================= class BubbleChart { constructor(container, options = {}) { this.container = typeof container === 'string' ? document.querySelector(container) : container; this.options = { colors: ['#f472b6', '#8b5cf6', '#84cc16', '#38bdf8', '#fb923c'], strokeWidth: 1, ...options }; this.data = options.data || []; this.init(); } init() { this.container.innerHTML = ''; this.render(); } setData(data) { this.data = data; this.render(); } render() { if (!this.data.length) return; this.container.innerHTML = ''; this.container.style.position = 'relative'; this.container.style.width = '100%'; this.container.style.aspectRatio = '1'; this.container.style.maxWidth = '18rem'; this.container.style.margin = '0 auto'; // Color scale by sector const sectors = Array.from(new Set(this.data.map(d => d.sector))); const color = d3.scaleOrdinal() .domain(sectors) .range(this.options.colors); // Pack layout const pack = d3.pack() .size([1000, 1000]) .padding(12); // Compute hierarchy const root = pack( d3.hierarchy({ children: this.data }) .sum(d => d.value) ); // Create nodes root.leaves().forEach(d => { const x = d.x; const y = d.y; const r = d.r; const fillColor = color(d.data.sector); const name = d.data.name; const value = d.data.value; const bubble = RosenCharts.createElement('div', { style: { position: 'absolute', transform: 'translate(-50%, -50%)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', left: `${(x / 1000) * 100}%`, top: `${(y / 1000) * 100}%`, width: `${((r * 2) / 1000) * 100}%`, height: `${((r * 2) / 1000) * 100}%`, borderRadius: '50%', backgroundColor: fillColor, border: `${this.options.strokeWidth}px solid rgba(255,255,255,0.2)`, cursor: 'default' }, title: `${name}\n${d3.format(',d')(value)}` }); if (value > 1000) { const nameEl = RosenCharts.createElement('div', { style: { color: 'white', textAlign: 'center', whiteSpace: 'nowrap', fontSize: `${r / 9}px`, lineHeight: `${r / 7}px` } }, [name]); const valueEl = RosenCharts.createElement('div', { style: { color: 'white', textAlign: 'center', whiteSpace: 'nowrap', opacity: 0.7, fontSize: `${r / 10}px`, lineHeight: `${r / 8}px` } }, [d3.format(',d')(value)]); bubble.appendChild(nameEl); bubble.appendChild(valueEl); } this.container.appendChild(bubble); }); } } // ============================================================================= // 4. SEMI-FILLED AREA CHART - Individual Asset Performance // ============================================================================= class SemiFilledAreaChart { constructor(container, options = {}) { this.container = typeof container === 'string' ? document.querySelector(container) : container; this.options = { height: 288, // h-72 color: { line: '#facc15', // yellow-400 areaFrom: 'rgba(234, 179, 8, 0.2)', // yellow-500/20 areaTo: 'rgba(234, 179, 8, 0.05)' // yellow-50/5 }, ...options }; this.data = options.data || []; this.init(); } init() { this.container.innerHTML = ''; this.processData(); this.render(); } setData(data) { this.data = data; this.processData(); this.render(); } processData() { // Convert date strings to Date objects this.processedData = this.data.map(d => ({ ...d, date: typeof d.date === 'string' ? new Date(d.date) : d.date })); } render() { if (!this.processedData || !this.processedData.length) return; this.container.innerHTML = ''; this.container.style.position = 'relative'; this.container.style.height = `${this.options.height}px`; this.container.style.width = '100%'; const data = this.processedData; // Scales const xScale = d3.scaleTime() .domain([data[0].date, data[data.length - 1].date]) .range([0, 100]); const yScale = d3.scaleLinear() .domain([0, d3.max(data, d => d.value) || 0]) .range([100, 0]); // Line generator const line = d3.line() .x(d => xScale(d.date)) .y(d => yScale(d.value)) .curve(d3.curveMonotoneX); // Area generator const area = d3.area() .x(d => xScale(d.date)) .y0(yScale(0)) .y1(d => yScale(d.value)) .curve(d3.curveMonotoneX); // Chart inner container const chartInner = RosenCharts.createElement('div', { style: { position: 'absolute', inset: 0, height: '100%', width: '100%', overflow: 'visible' } }); // SVG const svg = RosenCharts.createSVG('svg', { viewBox: '0 0 100 100', preserveAspectRatio: 'none', style: 'width: 100%; height: 100%; overflow: visible;' }); // Gradient for area const defs = RosenCharts.createSVG('defs'); const gradient = RosenCharts.createSVG('linearGradient', { id: 'semi-filled-gradient', x1: 0, x2: 0, y1: 0, y2: 1 }); const stop1 = RosenCharts.createSVG('stop', { offset: '0%', 'stop-color': this.options.color.areaFrom }); const stop2 = RosenCharts.createSVG('stop', { offset: '100%', 'stop-color': this.options.color.areaTo }); gradient.appendChild(stop1); gradient.appendChild(stop2); defs.appendChild(gradient); svg.appendChild(defs); // Area path const areaPath = RosenCharts.createSVG('path', { d: area(data), fill: 'url(#semi-filled-gradient)' }); svg.appendChild(areaPath); // Line path const linePath = RosenCharts.createSVG('path', { d: line(data), fill: 'none', stroke: this.options.color.line, 'stroke-width': '1.5', 'vector-effect': 'non-scaling-stroke' }); svg.appendChild(linePath); chartInner.appendChild(svg); // X-axis labels data.forEach((day, i) => { if (i % 6 !== 0 || i === 0 || i >= data.length - 3) return; const label = RosenCharts.createElement('div', { style: { position: 'absolute', left: `${xScale(day.date)}%`, top: '90%', fontSize: 'var(--wa-font-size-xs)', color: 'var(--wa-color-neutral-500)', transform: 'translateX(-50%)' } }, [day.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })]); chartInner.appendChild(label); }); this.container.appendChild(chartInner); // Y-axis labels yScale.ticks(8).forEach((value, i) => { if (i < 1) return; const label = RosenCharts.createElement('div', { style: { position: 'absolute', top: `${yScale(value)}%`, right: '3%', fontSize: 'var(--wa-font-size-xs)', fontVariantNumeric: 'tabular-nums', color: 'var(--wa-color-neutral-400)', transform: 'translateY(-50%)' } }, [value.toString()]); this.container.appendChild(label); }); } } // ============================================================================= // EXPORT FOR GLOBAL USE // ============================================================================= // Make available globally window.RosenCharts = RosenCharts; window.CandleChart = CandleChart; window.StackedAreaChart = StackedAreaChart; window.BubbleChart = BubbleChart; window.SemiFilledAreaChart = SemiFilledAreaChart;