/**
* 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;