package components
import (
"encoding/json"
"nebula/models"
)
type OHLCData struct {
Date string `json:"date"`
Open float64 `json:"open"`
High float64 `json:"high"`
Low float64 `json:"low"`
Close float64 `json:"close"`
}
type TimeSeriesData struct {
Date string `json:"date"`
Value float64 `json:"value"`
}
func toJSON(v interface{}) string {
b, _ := json.Marshal(v)
return string(b)
}
// RosenCharts global utilities and chart classes
script initRosenCharts() {
window.RosenCharts = {
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)',
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' }
},
formatNumber(value, decimals = 2) {
return new Intl.NumberFormat('en-US', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals
}).format(value);
},
formatCurrency(value) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(value);
},
isDesktop() {
return window.innerWidth > 1024;
},
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;
},
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 {
el.setAttribute(key, value);
}
});
children.forEach(child => {
if (typeof child === 'string') {
el.appendChild(document.createTextNode(child));
} else if (child) {
el.appendChild(child);
}
});
return el;
}
};
}
// CandleChart initialization script
script initCandleChart(containerId string, dataJSON string) {
const container = document.getElementById(containerId);
if (!container || !window.d3) return;
const data = JSON.parse(dataJSON);
const RC = window.RosenCharts;
const options = {
marginTop: 10,
marginRight: 60,
marginBottom: 56,
marginLeft: 30,
height: 288
};
let zoomLevel = 1;
let visibleRange = { start: 0, end: data.length - 1 };
let mousePosition = null;
let hoverData = null;
function getVisibleData() {
return data.slice(visibleRange.start, visibleRange.end + 1);
}
function render() {
container.innerHTML = '';
container.style.position = 'relative';
const visibleData = getVisibleData();
// Create scales
const xScale = d3.scaleBand()
.domain(visibleData.map(d => d.date))
.range([0, 100])
.padding(0.3);
const yMin = d3.min(visibleData, d => d.low) * 0.995;
const yMax = d3.max(visibleData, d => d.high) * 1.005;
const yScale = d3.scaleLinear()
.domain([yMin, yMax])
.range([100, 0]);
// Legend
const legend = RC.createElement('div', {
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)'
}
});
legend.innerHTML = `
Hover over chart to see OHLC data
`;
// Chart area
const chartArea = RC.createElement('div', {
style: {
position: 'relative',
height: options.height + 'px',
width: '100%'
}
});
// Y-axis
const yAxisContainer = RC.createElement('div', {
style: {
position: 'absolute',
height: 'calc(100% - ' + options.marginTop + 'px - ' + options.marginBottom + 'px)',
transform: 'translateY(' + options.marginTop + 'px)',
right: 'calc(' + options.marginRight + 'px - 1rem)',
overflow: 'visible'
}
});
const yTicks = yScale.ticks(6);
yTicks.forEach(value => {
const label = RC.createElement('div', {
style: {
position: 'absolute',
right: '0%',
top: 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);
});
// Chart inner
const chartInner = RC.createElement('div', {
style: {
position: 'absolute',
inset: 0,
height: 'calc(100% - ' + options.marginTop + 'px - ' + options.marginBottom + 'px)',
width: 'calc(100% - ' + options.marginLeft + 'px - ' + options.marginRight + 'px)',
transform: 'translate(' + options.marginLeft + 'px, ' + options.marginTop + 'px)',
overflow: 'visible'
}
});
// SVG for grid and wicks
const svg = RC.createSVG('svg', {
viewBox: '0 0 100 100',
preserveAspectRatio: 'none',
style: 'overflow: visible; width: 100%; height: 100%;'
});
// Grid lines
yTicks.forEach(value => {
const line = RC.createSVG('line', {
x1: 0, x2: 100,
y1: yScale(value), y2: 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
visibleData.forEach(d => {
const barX = xScale(d.date) + xScale.bandwidth() / 2;
const line = RC.createSVG('line', {
x1: barX, y1: yScale(d.high),
x2: barX, y2: yScale(d.low),
stroke: 'var(--wa-color-neutral-300)',
'stroke-width': 1,
'vector-effect': 'non-scaling-stroke'
});
svg.appendChild(line);
});
chartInner.appendChild(svg);
// Candle bodies
visibleData.forEach(d => {
const barWidth = xScale.bandwidth();
const barHeight = Math.abs(yScale(d.open) - yScale(d.close));
const barX = xScale(d.date);
const barY = yScale(Math.max(d.open, d.close));
const isUp = d.close > d.open;
const bar = RC.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);
});
chartArea.appendChild(yAxisContainer);
chartArea.appendChild(chartInner);
container.appendChild(legend);
container.appendChild(chartArea);
// Wheel zoom
chartArea.addEventListener('wheel', (e) => {
e.preventDefault();
const zoomIn = e.deltaY < 0;
const zoomFactor = 1.04;
const newZoomLevel = zoomIn
? Math.min(zoomLevel * zoomFactor, 20)
: Math.max(zoomLevel / zoomFactor, 1);
const visibleCount = Math.max(5, Math.floor(data.length / newZoomLevel));
const newEnd = data.length - 1;
const newStart = Math.max(0, newEnd - visibleCount + 1);
zoomLevel = newZoomLevel;
visibleRange = { start: newStart, end: newEnd };
render();
}, { passive: false });
}
render();
}
// StackedAreaChart initialization script
script initStackedAreaChart(containerId string, dataJSON string) {
const container = document.getElementById(containerId);
if (!container || !window.d3) return;
const data = JSON.parse(dataJSON);
const RC = window.RosenCharts;
const colors = [
{ from: '#f5d0fe', to: '#e879f9', bg: '#e879f9' },
{ from: '#c4b5fd', to: '#a855f7', bg: '#a855f7' },
{ from: '#bfdbfe', to: '#3b82f6', bg: '#3b82f6' },
{ from: '#bae6fd', to: '#38bdf8', bg: '#38bdf8' },
{ from: '#fed7aa', to: '#fb923c', bg: '#fb923c' }
];
container.innerHTML = '';
container.style.position = 'relative';
const parseDate = d3.utcParse('%Y-%m-%d');
const industries = Array.from(new Set(data.map(d => d.industry)));
const groupedData = Array.from(
d3.group(data, d => d.date),
([date, values]) => {
const obj = { date: parseDate(date.split('T')[0]) };
values.forEach(val => {
obj[val.industry] = val.value || 0;
});
return obj;
}
);
const series = d3.stack().keys(industries)(groupedData);
// Legend
const legendContainer = RC.createElement('div', {
style: {
display: 'flex',
textAlign: 'center',
gap: '16px',
fontSize: 'var(--wa-font-size-xs)',
overflowX: 'auto',
padding: '0 40px',
marginBottom: '32px'
}
});
industries.slice().reverse().forEach((industry, i) => {
const colorIndex = industries.length - 1 - i;
const color = colors[colorIndex % colors.length];
const item = RC.createElement('div', {
style: { display: 'flex', gap: '6px', alignItems: 'center' }
});
const dot = RC.createElement('div', {
style: {
width: '4px',
height: '16px',
borderRadius: '9999px',
background: color.bg
}
});
item.appendChild(dot);
item.appendChild(document.createTextNode(industry));
legendContainer.appendChild(item);
});
// Chart container
const chartContainer = RC.createElement('div', {
style: {
position: 'relative',
height: '256px',
width: '100%'
}
});
// Scales
const xScale = d3.scaleUtc()
.domain(d3.extent(groupedData, d => d.date))
.range([0, 100]);
const yScale = d3.scaleLinear()
.domain([0, d3.max(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 = RC.createSVG('svg', {
style: 'position: absolute; inset: 0; z-index: 10; height: 100%; width: 100%; overflow: visible;'
});
const innerSvg = RC.createSVG('svg', {
viewBox: '0 0 100 100',
preserveAspectRatio: 'none',
style: 'overflow: visible;'
});
// Gradients
const defs = RC.createSVG('defs');
series.forEach((_, i) => {
const color = colors[i % colors.length];
const gradient = RC.createSVG('linearGradient', {
id: 'stacked-area-gradient-' + containerId + '-' + i,
x1: 0, x2: 0.5, y1: 0.25, y2: 1
});
const stop1 = RC.createSVG('stop', { offset: '0%', 'stop-color': color.from });
const stop2 = RC.createSVG('stop', { offset: '80%', 'stop-color': color.to });
gradient.appendChild(stop1);
gradient.appendChild(stop2);
defs.appendChild(gradient);
});
innerSvg.appendChild(defs);
// Areas
series.forEach((layer, layerIndex) => {
const path = RC.createSVG('path', {
fill: 'url(#stacked-area-gradient-' + containerId + '-' + layerIndex + ')',
d: area(layer),
stroke: '#ccc',
'stroke-width': '0.05'
});
innerSvg.appendChild(path);
});
svg.appendChild(innerSvg);
chartContainer.appendChild(svg);
container.appendChild(legendContainer);
container.appendChild(chartContainer);
}
// BubbleChart initialization script
script initBubbleChart(containerId string, dataJSON string) {
const container = document.getElementById(containerId);
if (!container || !window.d3) return;
const data = JSON.parse(dataJSON);
const RC = window.RosenCharts;
const colors = ['#f472b6', '#8b5cf6', '#84cc16', '#38bdf8', '#fb923c'];
container.innerHTML = '';
container.style.position = 'relative';
container.style.width = '100%';
container.style.aspectRatio = '1';
container.style.maxWidth = '18rem';
container.style.margin = '0 auto';
const sectors = Array.from(new Set(data.map(d => d.sector)));
const color = d3.scaleOrdinal().domain(sectors).range(colors);
const pack = d3.pack().size([1000, 1000]).padding(12);
const root = pack(
d3.hierarchy({ children: data }).sum(d => d.value)
);
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 = RC.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: '1px solid rgba(255,255,255,0.2)',
cursor: 'default'
},
title: name + '\n' + d3.format(',d')(value)
});
if (value > 1000) {
const nameEl = RC.createElement('div', {
style: {
color: 'white',
textAlign: 'center',
whiteSpace: 'nowrap',
fontSize: (r / 9) + 'px',
lineHeight: (r / 7) + 'px'
}
}, [name]);
const valueEl = RC.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);
}
container.appendChild(bubble);
});
}
// SemiFilledAreaChart initialization script
script initSemiFilledAreaChart(containerId string, dataJSON string, lineColor string, areaFromColor string, areaToColor string) {
const container = document.getElementById(containerId);
if (!container || !window.d3) return;
const data = JSON.parse(dataJSON).map(d => ({
...d,
date: new Date(d.date)
}));
const RC = window.RosenCharts;
container.innerHTML = '';
container.style.position = 'relative';
container.style.height = '288px';
container.style.width = '100%';
// 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
const chartInner = RC.createElement('div', {
style: {
position: 'absolute',
inset: 0,
height: '100%',
width: '100%',
overflow: 'visible'
}
});
// SVG
const svg = RC.createSVG('svg', {
viewBox: '0 0 100 100',
preserveAspectRatio: 'none',
style: 'width: 100%; height: 100%; overflow: visible;'
});
// Gradient
const defs = RC.createSVG('defs');
const gradient = RC.createSVG('linearGradient', {
id: 'semi-filled-gradient-' + containerId,
x1: 0, x2: 0, y1: 0, y2: 1
});
const stop1 = RC.createSVG('stop', { offset: '0%', 'stop-color': areaFromColor });
const stop2 = RC.createSVG('stop', { offset: '100%', 'stop-color': areaToColor });
gradient.appendChild(stop1);
gradient.appendChild(stop2);
defs.appendChild(gradient);
svg.appendChild(defs);
// Area path
const areaPath = RC.createSVG('path', {
d: area(data),
fill: 'url(#semi-filled-gradient-' + containerId + ')'
});
svg.appendChild(areaPath);
// Line path
const linePath = RC.createSVG('path', {
d: line(data),
fill: 'none',
stroke: lineColor,
'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 = RC.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);
});
container.appendChild(chartInner);
// Y-axis labels
yScale.ticks(8).forEach((value, i) => {
if (i < 1) return;
const label = RC.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()]);
container.appendChild(label);
});
}
// CandleChart component
templ CandleChart(id string, data []OHLCData) {
@initRosenCharts()
@initCandleChart(id, toJSON(data))
}
templ StackedAreaChart(id string, data []models.AreaSeriesData) {
@initRosenCharts()
@initStackedAreaChart(id, toJSON(data))
}
templ BubbleChart(id string, data []models.BubbleData) {
@initRosenCharts()
@initBubbleChart(id, toJSON(data))
}
// SemiFilledAreaChart component with customizable colors
templ SemiFilledAreaChart(id string, data []TimeSeriesData) {
@SemiFilledAreaChartWithColors(id, data, "#facc15", "rgba(234, 179, 8, 0.2)", "rgba(234, 179, 8, 0.05)")
}
// SemiFilledAreaChart with custom colors
templ SemiFilledAreaChartWithColors(id string, data []TimeSeriesData, lineColor string, areaFromColor string, areaToColor string) {
@initRosenCharts()
@initSemiFilledAreaChart(id, toJSON(data), lineColor, areaFromColor, areaToColor)
}
// Chart card wrapper for consistent styling
templ ChartCard(title string, subtitle string) {
{ title }
if subtitle != "" {
{ subtitle }
}
{ children... }
}