1083 lines
32 KiB
JavaScript
1083 lines
32 KiB
JavaScript
|
|
/**
|
||
|
|
* 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 = `
|
||
|
|
<div style="font-size: var(--wa-font-size-xs); color: var(--wa-color-neutral-500);">
|
||
|
|
Zoom: ${this.zoomLevel.toFixed(1)}x | Showing ${visibleData.length} of ${this.data.length} candles
|
||
|
|
</div>
|
||
|
|
<div style="display: flex; gap: 16px;">
|
||
|
|
<span>O: <strong>${this.hoverData.open.toFixed(2)}</strong></span>
|
||
|
|
<span>H: <strong>${this.hoverData.high.toFixed(2)}</strong></span>
|
||
|
|
<span>L: <strong>${this.hoverData.low.toFixed(2)}</strong></span>
|
||
|
|
<span>C: <strong>${this.hoverData.close.toFixed(2)}</strong></span>
|
||
|
|
<span style="color: ${isPositive ? 'var(--wa-color-success)' : 'var(--wa-color-danger)'};">
|
||
|
|
${changePercent}%
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
} else {
|
||
|
|
this.legend.innerHTML = `
|
||
|
|
<div style="color: var(--wa-color-neutral-400);">Hover over chart to see OHLC data</div>
|
||
|
|
<div></div>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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;
|