Beautiful charts. Built using templ. Copy and paste into your apps.
Introducing Charts. A collection of chart components that you can copy and paste into your apps.
Charts are designed to look great out of the box. They work well with the other components and are fully customizable to fit your project.
Component
We use a small client runtime under the hood that draws Recharts compatible SVG and ports Recharts’ and d3’s exact algorithms.
We designed the chart component with composition in mind. You build your charts using chart components and only bring in custom components, such as the tooltip, when and where you need it.
import "github.com/axadrn/shadcn-templ/v2/components/chart" templ MyChart() { @chart.Container(chart.ContainerProps{Config: chartConfig}) { @chart.BarChart(chart.BarChartProps{Data: chartData}) { @chart.Bar(chart.BarProps{DataKey: "value"}) @chart.Tooltip() } }}The chart elements mirror the Recharts composition one to one. This means you’re not locked into a shadcn-templ abstraction: shadcn and Recharts chart examples translate directly, element for element.
The runtime watches the DOM, so charts arriving through htmx or Datastar swaps render out of the box, with no framework specific wiring.
The components are yours.
Installation
shadcn-templ add chartCopy and paste the following code into your project.
package chart import ( "context" "encoding/json" "fmt" "io" "sort" "strings" "github.com/axadrn/shadcn-templ/v2/utils") // Config is the pendant of shadcn's ChartConfig: label and color per// series key. The container turns it into --color-<key> variables.type Config []Series type Series struct { Key string Label string Color string // Theme sets a color per color scheme, like the theme field in // shadcn's ChartConfig. It takes precedence over Color. Theme *SeriesTheme // Icon replaces the color swatch in legend and tooltip, like the icon // field in shadcn's ChartConfig. Icon templ.Component} // SeriesTheme is a color pair for the light and the dark scheme.type SeriesTheme struct { Light string Dark string} // styleBlock is the ChartStyle pendant: one rule per color scheme. The// theme color wins over the plain color, and a theme without a value for// the scheme falls back to the color, like itemConfig.theme?.[theme] ??// itemConfig.color. Without any colored entry there is no style at all,// like ChartStyle returning null.func (c Config) styleBlock(id string) string { hasColor := false for _, s := range c { if s.Color != "" || s.Theme != nil { hasColor = true break } } if !hasColor { return "" } var sb strings.Builder for _, theme := range []struct{ Name, Prefix string }{{"light", ""}, {"dark", ".dark "}} { fmt.Fprintf(&sb, "%s[data-chart=%s] {\n", theme.Prefix, id) for _, s := range c { color := s.Color if s.Theme != nil { themed := s.Theme.Light if theme.Name == "dark" { themed = s.Theme.Dark } if themed != "" { color = themed } } if color != "" { fmt.Fprintf(&sb, " --color-%s: %s;\n", s.Key, color) } } sb.WriteString("}\n") } return sb.String()} // ContainerProps is the pendant of ChartContainer's props.type ContainerProps struct { // ID names the chart, a generated one is used when empty. It ends up // as data-chart="chart-<id>" like in shadcn. ID string Config Config Class string Attributes templ.Attributes} // StyleProps is the pendant of ChartStyle's props.type StyleProps struct { ID string Config Config} // Style is the ChartStyle pendant: the color variables for an id without// a container, for markup that needs them outside the chart (like the// select swatches in the interactive pie demo).templ Style(props ...StyleProps) { {{ var p StyleProps }} if len(props) > 0 { {{ p = props[0] }} } if block := p.Config.styleBlock(p.ID); block != "" { @templ.Raw("<style>" + block + "</style>") }} // Container is the ChartContainer pendant: aspect-video flex box with the// Recharts-compat CSS and the generated color variables.templ Container(props ...ContainerProps) { {{ var p ContainerProps }} if len(props) > 0 { {{ p = props[0] }} } {{ id := p.ID if id == "" { id = utils.RandomID() } chartID := "chart-" + id // The config travels to the chart root and its children through ctx, // the pendant of shadcn's ChartContext. ctx = context.WithValue(ctx, configCtxKey, p.Config) }} <div data-slot="chart" data-chart={ chartID } data-tui-chart class={ utils.CN( "cn-chart flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden", p.Class, ) } { p.Attributes... } > if block := p.Config.styleBlock(chartID); block != "" { @templ.Raw("<style>" + block + "</style>") } { children... } </div>} // Datum is one row of chart data, the pendant of the plain objects in// shadcn's chartData arrays.type Datum map[string]any // Margin replaces Recharts' default margin of 5 on every side entirely// when set, like the Recharts margin prop.type Margin struct { Top, Right, Bottom, Left float64} type CurveType string const CurveNatural CurveType = "natural" // CartesianGridProps is the pendant of Recharts' CartesianGrid. Both// directions default to Recharts' true, so they are pointers: pass// Bool(false) to turn one off, like vertical={false} in the tsx.type CartesianGridProps struct { Horizontal *bool Vertical *bool} // Bool returns a pointer to v, for the props whose Recharts default is// true.func Bool(v bool) *bool { return &v} // Float returns a pointer to v, for the numeric props that carry a// meaningful zero.func Float(v float64) *float64 { return &v} // Index returns a pointer to i, for the optional index props.func Index(i int) *int { return &i} // boolOr resolves an optional prop against its Recharts default.func boolOr(v *bool, def bool) bool { if v == nil { return def } return *v} // XAxisProps is the pendant of Recharts' XAxis.type XAxisProps struct { DataKey string // Type is "category" (default) or "number", the value axis in a // vertical layout. Type string Hide bool TickLine bool AxisLine bool TickMargin float64 MinTickGap float64 // defaults to Recharts' 5 TickFormatter func(any) string} // YAxisProps is the pendant of Recharts' YAxis.type YAxisProps struct { DataKey string // Type is "number" (default) or "category", the label axis in a // vertical layout. Type string Hide bool TickLine bool AxisLine bool TickMargin float64 TickCount int // defaults to Recharts 5 Width float64 // defaults to Recharts 60 TickFormatter func(any) string} // TooltipProps is the pendant of ChartTooltip: the cursor flag and the// ChartTooltipContent it renders.type TooltipProps struct { // Cursor defaults to Recharts' true, so it is a pointer: pass // Bool(false) to turn it off, like cursor={false} in the tsx. Cursor *bool Content TooltipContentProps // DefaultIndex shows the tooltip on mount at that category, the // pendant of defaultIndex={1} in the tsx. DefaultIndex *int} // TooltipContentProps is the pendant of ChartTooltipContent.type TooltipContentProps struct { Indicator string // "dot" (default), "line", "dashed" LabelKey string HideLabel bool HideIndicator bool NameKey string Class string // extra content class, e.g. "w-[150px]" LabelClassName string // extra label class, ChartTooltipContent's labelClassName // Color overrides the indicator color for every row, the color prop of // ChartTooltipContent. Color string LabelFormatter func(any) string // Formatter is the pendant of the formatter render prop: it replaces a // row's default indicator, name and value markup. It runs on the server // for every data row and series, the tooltip shows the prerendered // result. item is the data row, index the series position. Formatter func(value any, name string, item Datum, index int) templ.Component} // LinearGradientProps is the pendant of a linearGradient element in the// chart defs, declared by the demos that fill their areas with a// gradient. Stops holds the rendered stop children.type LinearGradientProps struct { ID string X1, Y1, X2, Y2 string Stops string} // AreaProps is the pendant of one Recharts Area.type AreaProps struct { DataKey string Type CurveType // Fill and Stroke are used verbatim, e.g. "url(#fillDesktop)" or // "var(--color-desktop)". Empty falls back to the series color. Fill string Stroke string StackID string FillOpacity float64 // 0 uses Recharts' default 0.6} // BarProps is the pendant of one Recharts Bar.type BarProps struct { DataKey string Fill string StackID string // Radius is Recharts' radius union: a float64 for all corners or a // []float64 of four corners, e.g. []float64{0, 0, 4, 4}. Radius any StrokeWidth float64 // ActiveIndex and ActiveBar are Recharts' active bar props: the bar at // that index renders with the ActiveBar styling. ActiveIndex *int ActiveBar *RectangleProps} // RectangleProps is the pendant of the Recharts Rectangle used as the// active bar shape.type RectangleProps struct { FillOpacity float64 Stroke string StrokeDasharray float64 StrokeDashoffset float64} // CellProps is the pendant of Recharts' Cell: the fill of one data row.type CellProps struct { Fill string} // LineProps is the pendant of one Recharts Line.type LineProps struct { DataKey string Type CurveType Stroke string StrokeWidth float64 // Dot draws the per point dots; nil is the demos' dot={false}. Dot *DotProps // ActiveDot sizes the hover dot, like Recharts' activeDot prop. ActiveDot *ActiveDotProps} // DotProps is the pendant of Recharts' dot prop: the object form sets// radius and fill, DataFill is the custom dot reading payload.fill and// Icon is the custom dot rendering a component.type DotProps struct { R float64 FillOpacity float64 Fill string DataFill bool Icon templ.Component Size float64 // icon box, like the custom dot's width/height} // ActiveDotProps is the pendant of Recharts' activeDot object.type ActiveDotProps struct { R float64} // LabelListProps is the pendant of Recharts' LabelList.type LabelListProps struct { // Position is "top", "insideLeft" or "right". Position string Offset float64 FontSize float64 FillOpacity float64 Class string DataKey string Formatter func(any) string} // LegendProps is the pendant of ChartLegend plus ChartLegendContent.type LegendProps struct { // NameKey picks the config entry for the label, like the nameKey of // ChartLegendContent. NameKey string Class string // VerticalAlign "top" places the legend above the plot with a bottom // padding, like Recharts' verticalAlign; everything else is "bottom". VerticalAlign string // HideIcon drops the config icon and falls back to the color swatch, // ChartLegendContent's hideIcon. HideIcon bool} // LineChartProps is the pendant of the Recharts LineChart root.type LineChartProps struct { // AccessibilityLayer adds the Recharts keyboard layer: the chart is // focusable and the arrow keys walk the tooltip through the categories. AccessibilityLayer bool Data []Datum Margin *Margin} // PieProps is the pendant of one Recharts Pie.type PieProps struct { Data []Datum DataKey string NameKey string InnerRadius float64 // OuterRadius defaults to Recharts' 80% of the available radius. OuterRadius float64 StrokeWidth float64 // Stroke is the separator between the sectors, Recharts' "#fff"; the // demos pass "0" to drop it. Stroke string // Label renders the value labels outside the sectors, LabelLine the // connecting line, on by default like Recharts. Label *PieLabelProps LabelLine *bool // ActiveIndex and ActiveShape are Recharts' active sector props: the // sector at that index renders as the given shapes instead. ActiveIndex *int ActiveShape []SectorProps} // PieLabelProps is the pendant of the label prop of a Pie.type PieLabelProps struct { // Fill overrides the sector color the labels inherit. Fill string} // SectorProps is the pendant of a Recharts Sector, the offsets it applies// to the sector radii.type SectorProps struct { InnerRadius float64 OuterRadius float64} // LabelProps is the pendant of the Label content function of the demos:// the text in the middle of a donut or a radial chart, written as the// tspans of the source.type LabelProps struct { Spans []LabelSpan // DominantBaseline is the attribute of the text element, which the // sources either set to "middle" or leave off. DominantBaseline string} // LabelSpan is one tspan of a center label.type LabelSpan struct { Text string Class string // OffsetY shifts the line off the center, the "cy + 24" of the source. OffsetY float64} // AreaChartProps is the pendant of the Recharts AreaChart root.type AreaChartProps struct { // AccessibilityLayer adds the Recharts keyboard layer: the chart is // focusable and the arrow keys walk the tooltip through the categories. AccessibilityLayer bool Data []Datum Margin *Margin // StackOffset "expand" normalizes each stack to 100%, the pendant of // Recharts' stackOffset prop. StackOffset string} // BarChartProps is the pendant of the Recharts BarChart root.type BarChartProps struct { // AccessibilityLayer adds the Recharts keyboard layer: the chart is // focusable and the arrow keys walk the tooltip through the categories. AccessibilityLayer bool Data []Datum // Layout "vertical" draws the bars horizontally, like the Recharts // layout prop. Layout string Margin *Margin} // RadarChartProps is the pendant of the Recharts RadarChart root.type RadarChartProps struct { Data []Datum Margin *Margin} // PolarGridProps is the pendant of Recharts' PolarGrid.type PolarGridProps struct { // GridType is "polygon" (default) or "circle". GridType string // RadialLines defaults to Recharts' true. RadialLines *bool // PolarRadius replaces the radius axis ticks with fixed radii. PolarRadius []float64 // Stroke is the grid color, Recharts' "#ccc"; the radial demos pass // "none" and paint the rings through their class instead. Stroke string StrokeWidth float64 Class string} // PolarAngleAxisProps is the pendant of Recharts' PolarAngleAxis.type PolarAngleAxisProps struct { DataKey string // Tick is the pendant of the tick render prop: it builds the tick // content per data row, the axis still places it. Tick func(Datum, int) TickContent} // TickContent is the text of one custom axis tick.type TickContent struct { Spans []TickSpan FontSize float64 FontWeight string // OffsetY shifts this tick, like the demos nudging the top one. OffsetY float64} // TickSpan is one tspan of a custom tick.type TickSpan struct { Text string Class string FontSize float64 // Dy starts a new line, ResetX puts it back at the tick's x. Dy string ResetX bool} // RadarProps is the pendant of one Recharts Radar.type RadarProps struct { DataKey string Fill string // FillOpacity is a pointer because zero is a meaningful value: an // unset prop leaves the SVG default of 1 in place. FillOpacity *float64 Stroke string StrokeWidth float64 Dot *DotProps} // RadialBarChartProps is the pendant of the Recharts RadialBarChart root.type RadialBarChartProps struct { Data []Datum // StartAngle is Recharts' zero by default, so a plain value carries it. // EndAngle defaults to a full turn, which an unset field cannot say, // so it is a pointer. StartAngle float64 EndAngle *float64 InnerRadius float64 // OuterRadius defaults to Recharts' 80% of the available radius. OuterRadius float64 Margin *Margin} // RadialBarProps is the pendant of one Recharts RadialBar.type RadialBarProps struct { DataKey string Fill string // Background draws the track behind the bar, over the full angle range. Background bool CornerRadius float64 StackID string Class string} // PolarRadiusAxisProps is the pendant of Recharts' PolarRadiusAxis. The// demos switch its parts off and keep it for the Label in the middle.type PolarRadiusAxisProps struct { // Tick, TickLine and AxisLine all default to Recharts' true. Tick *bool TickLine *bool AxisLine *bool} // PieChartProps is the pendant of the Recharts PieChart root.type PieChartProps struct{} const ( defaultXAxisHeight = 30.0 defaultLegendHeight = 28.0) // chartState is the collector the chart root shares with its children// through ctx, the pendant of Recharts gathering its children's props:// the child components register themselves here and render nothing, the// root builds the model afterwards.type chartState struct { kind string data []Datum margin *Margin stackOffset string grid *CartesianGridProps layout string x *XAxisProps y *YAxisProps tooltip *TooltipProps legend *LegendProps defs []LinearGradientProps areas []AreaProps bars []*barState lines []*lineState pies []*pieState polarGrid *PolarGridProps angleAxis *PolarAngleAxisProps radars []RadarProps radialBars []*radialBarState radiusAxis *PolarRadiusAxisProps center *LabelProps startAngle float64 endAngle *float64 innerRadius float64 outerRadius float64 // accessibilityLayer marks the Recharts keyboard layer on the root. accessibilityLayer bool} // radialBarState pairs a radial bar with the LabelList its children// registered.type radialBarState struct { props RadialBarProps labelList *LabelListProps} // lineState pairs a line with the LabelList its children registered.type lineState struct { props LineProps labelList *LabelListProps} // pieState pairs a pie with the Label and LabelList children it collected.type pieState struct { props PieProps label *LabelProps labelList *LabelListProps} // barState pairs a bar with the LabelList and Cell children it collected.type barState struct { props BarProps labelLists []LabelListProps cells []CellProps} type ctxKey int const ( configCtxKey ctxKey = iota stateCtxKey) func configFrom(ctx context.Context) Config { if c, ok := ctx.Value(configCtxKey).(Config); ok { return c } return nil} func stateFrom(ctx context.Context) *chartState { if s, ok := ctx.Value(stateCtxKey).(*chartState); ok { return s } return nil} func num(v any) float64 { switch n := v.(type) { case float64: return n case float32: return float64(n) case int: return float64(n) case int64: return float64(n) } return 0} func str(v any) string { return fmt.Sprint(v)} // renderHTML renders a templ component (e.g. a config icon) to raw HTML// for the model payload.func renderHTML(ctx context.Context, c templ.Component) string { if c == nil { return "" } var sb strings.Builder if err := c.Render(ctx, &sb); err != nil { return "" } return sb.String()} // applyIcons copies the config icons into the model series.func applyIcons(ctx context.Context, m *Model, config Config) { for i := range m.Series { for _, s := range config { if s.Key == m.Series[i].Key && s.Icon != nil { m.Series[i].Icon = renderHTML(ctx, s.Icon) } } }} // seriesColor resolves the fill/stroke for a data key: the container's// generated --color-<key> variable.func seriesColor(key string) string { return "var(--color-" + key + ")"} func (c Config) Label(key string) string { for _, s := range c { if s.Key == key { return s.Label } } return key} // AreaChart is the Recharts AreaChart root: the children declare axes,// grid, tooltip and areas, the root collects them and emits the model// payload for the client renderer.templ AreaChart(props ...AreaChartProps) { {{ var p AreaChartProps }} if len(props) > 0 { {{ p = props[0] }} } {{ st := &chartState{kind: "area", data: p.Data, margin: p.Margin, stackOffset: p.StackOffset, accessibilityLayer: p.AccessibilityLayer} ctx = context.WithValue(ctx, stateCtxKey, st) }} <div style="position:relative;width:100%;height:100%"> { children... } @chartOutput(st) </div>} // BarChart is the Recharts BarChart root.templ BarChart(props ...BarChartProps) { {{ var p BarChartProps }} if len(props) > 0 { {{ p = props[0] }} } {{ st := &chartState{kind: "bar", data: p.Data, margin: p.Margin, layout: p.Layout, accessibilityLayer: p.AccessibilityLayer} ctx = context.WithValue(ctx, stateCtxKey, st) }} <div style="position:relative;width:100%;height:100%"> { children... } @chartOutput(st) </div>} // LineChart is the Recharts LineChart root.templ LineChart(props ...LineChartProps) { {{ var p LineChartProps }} if len(props) > 0 { {{ p = props[0] }} } {{ st := &chartState{kind: "line", data: p.Data, margin: p.Margin, accessibilityLayer: p.AccessibilityLayer} ctx = context.WithValue(ctx, stateCtxKey, st) }} <div style="position:relative;width:100%;height:100%"> { children... } @chartOutput(st) </div>} // RadarChart is the Recharts RadarChart root.templ RadarChart(props ...RadarChartProps) { {{ var p RadarChartProps }} if len(props) > 0 { {{ p = props[0] }} } {{ st := &chartState{kind: "radar", data: p.Data, margin: p.Margin} ctx = context.WithValue(ctx, stateCtxKey, st) }} <div style="position:relative;width:100%;height:100%"> { children... } @chartOutput(st) </div>} // PolarGrid registers the polar grid.templ PolarGrid(props ...PolarGridProps) { {{ var p PolarGridProps }} if len(props) > 0 { {{ p = props[0] }} } {{ if st := stateFrom(ctx); st != nil { st.polarGrid = &p } }}} // PolarAngleAxis registers the angle axis, the labels around the chart.templ PolarAngleAxis(props ...PolarAngleAxisProps) { {{ var p PolarAngleAxisProps }} if len(props) > 0 { {{ p = props[0] }} } {{ if st := stateFrom(ctx); st != nil { st.angleAxis = &p } }}} // Radar registers one radar series.templ Radar(props ...RadarProps) { {{ var p RadarProps }} if len(props) > 0 { {{ p = props[0] }} } {{ if st := stateFrom(ctx); st != nil { st.radars = append(st.radars, p) } }}} // RadialBarChart is the Recharts RadialBarChart root.templ RadialBarChart(props ...RadialBarChartProps) { {{ var p RadialBarChartProps }} if len(props) > 0 { {{ p = props[0] }} } {{ st := &chartState{ kind: "radial", data: p.Data, margin: p.Margin, startAngle: p.StartAngle, endAngle: p.EndAngle, innerRadius: p.InnerRadius, outerRadius: p.OuterRadius, } ctx = context.WithValue(ctx, stateCtxKey, st) }} <div style="position:relative;width:100%;height:100%"> { children... } @chartOutput(st) </div>} // RadialBar registers one radial bar series.templ RadialBar(props ...RadialBarProps) { {{ var p RadialBarProps }} if len(props) > 0 { {{ p = props[0] }} } {{ if st := stateFrom(ctx); st != nil { st.radialBars = append(st.radialBars, &radialBarState{props: p}) } }} { children... }} // PolarRadiusAxis registers the radius axis, which the demos use to put// the Label in the middle of the chart.templ PolarRadiusAxis(props ...PolarRadiusAxisProps) { {{ var p PolarRadiusAxisProps }} if len(props) > 0 { {{ p = props[0] }} } {{ if st := stateFrom(ctx); st != nil { st.radiusAxis = &p } }} { children... }} // PieChart is the Recharts PieChart root.templ PieChart(props ...PieChartProps) { {{ st := &chartState{kind: "pie"} }} {{ ctx = context.WithValue(ctx, stateCtxKey, st) }} <div style="position:relative;width:100%;height:100%"> { children... } @chartOutput(st) </div>} // chartOutput builds the model from what the children registered and// emits the payload plus the SSR legend.templ chartOutput(st *chartState) { {{ m := buildModel(ctx, st) }} @templ.Raw(ModelScript(m)) if st.legend != nil { @legendContent(legendItems(configFrom(ctx), m, st, st.legend), st.legend) }} // CartesianGrid registers the grid, the pendant of Recharts'// CartesianGrid element. It renders nothing, the chart root draws.templ CartesianGrid(props ...CartesianGridProps) { {{ var p CartesianGridProps }} if len(props) > 0 { {{ p = props[0] }} } {{ if st := stateFrom(ctx); st != nil { st.grid = &p } }}} // XAxis registers the x axis.templ XAxis(props ...XAxisProps) { {{ var p XAxisProps }} if len(props) > 0 { {{ p = props[0] }} } {{ if st := stateFrom(ctx); st != nil { st.x = &p } }}} // YAxis registers the y axis.templ YAxis(props ...YAxisProps) { {{ var p YAxisProps }} if len(props) > 0 { {{ p = props[0] }} } {{ if st := stateFrom(ctx); st != nil { st.y = &p } }}} // Tooltip registers the ChartTooltip with its ChartTooltipContent.templ Tooltip(props ...TooltipProps) { {{ var p TooltipProps }} if len(props) > 0 { {{ p = props[0] }} } {{ if st := stateFrom(ctx); st != nil { st.tooltip = &p } }}} // Legend registers the ChartLegend, rendered by the chart root.templ Legend(props ...LegendProps) { {{ var p LegendProps }} if len(props) > 0 { {{ p = props[0] }} } {{ if st := stateFrom(ctx); st != nil { st.legend = &p } }}} // Defs groups the gradient definitions like the svg defs element.templ Defs() { { children... }} // LinearGradient registers one gradient definition. Its children are the// raw stop elements, passed through into the rendered defs verbatim like// Recharts passes defs children through.func LinearGradient(props ...LinearGradientProps) templ.Component { return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { var p LinearGradientProps if len(props) > 0 { p = props[0] } st := stateFrom(ctx) if st == nil { return nil } var sb strings.Builder if children := templ.GetChildren(ctx); children != nil { if err := children.Render(templ.ClearChildren(ctx), &sb); err != nil { return err } } p.Stops = strings.TrimSpace(sb.String()) st.defs = append(st.defs, p) return nil })} // Area registers one area series. Declaration order is paint order,// like in Recharts.templ Area(props ...AreaProps) { {{ var p AreaProps }} if len(props) > 0 { {{ p = props[0] }} } {{ if st := stateFrom(ctx); st != nil { st.areas = append(st.areas, p) } }}} // Bar registers one bar series.templ Bar(props ...BarProps) { {{ var p BarProps }} if len(props) > 0 { {{ p = props[0] }} } {{ if st := stateFrom(ctx); st != nil { st.bars = append(st.bars, &barState{props: p}) } }} { children... }} // Cell is the pendant of Recharts' Cell: the fill of one data row of the// enclosing bar.templ Cell(props ...CellProps) { {{ var p CellProps }} if len(props) > 0 { {{ p = props[0] }} } {{ if st := stateFrom(ctx); st != nil && len(st.bars) > 0 { b := st.bars[len(st.bars)-1] b.cells = append(b.cells, p) } }}} // Line registers one line series. The LabelList child attaches to it.templ Line(props ...LineProps) { {{ var p LineProps }} if len(props) > 0 { {{ p = props[0] }} } {{ if st := stateFrom(ctx); st != nil { st.lines = append(st.lines, &lineState{props: p}) } }} { children... }} // LabelList registers the value labels of its enclosing line.templ LabelList(props ...LabelListProps) { {{ var p LabelListProps }} if len(props) > 0 { {{ p = props[0] }} } {{ if st := stateFrom(ctx); st != nil { if len(st.radialBars) > 0 { st.radialBars[len(st.radialBars)-1].labelList = &p } else if len(st.pies) > 0 { st.pies[len(st.pies)-1].labelList = &p } else if len(st.bars) > 0 { b := st.bars[len(st.bars)-1] b.labelLists = append(b.labelLists, p) } else if len(st.lines) > 0 { st.lines[len(st.lines)-1].labelList = &p } } }}} // Pie registers the pie with its data and geometry.templ Pie(props ...PieProps) { {{ var p PieProps }} if len(props) > 0 { {{ p = props[0] }} } {{ if st := stateFrom(ctx); st != nil { st.pies = append(st.pies, &pieState{props: p}) } }} { children... }} // Label registers the center label: on a Pie the donut hole, on a radial// chart the middle of the rings.templ Label(props ...LabelProps) { {{ var p LabelProps }} if len(props) > 0 { {{ p = props[0] }} } {{ if st := stateFrom(ctx); st != nil { if len(st.pies) > 0 { st.pies[len(st.pies)-1].label = &p } else { st.center = &p } } }}} // radiusCorners normalizes Recharts' radius union into corner radii:// a number applies to all four corners, a slice is used as given.func radiusCorners(v any) []float64 { switch r := v.(type) { case nil: return nil case float64: return []float64{r} case int: return []float64{float64(r)} case []float64: return r } return nil} // hasFillColumn reports whether the rows carry their own fill, which// Recharts uses as the bar fill of that row.func hasFillColumn(data []Datum) bool { for _, row := range data { if _, ok := row["fill"]; ok { return true } } return false} // seriesMin is the lowest value across all series, so a negative domain// keeps its zero baseline.func seriesMin(series []ModelSeries) float64 { min := 0.0 for _, s := range series { for _, v := range s.Values { if v < min { min = v } } } return min} // labelListModel precomputes the labels of a LabelList, defaulting to the// series data key like Recharts does.func labelListModel(ll LabelListProps, seriesKey string, data []Datum) LabelListModel { key := ll.DataKey if key == "" { key = seriesKey } lm := LabelListModel{ Position: ll.Position, Offset: ll.Offset, FontSize: ll.FontSize, FillOpacity: ll.FillOpacity, Class: ll.Class, Labels: make([]string, len(data)), } for i, row := range data { if ll.Formatter != nil { lm.Labels[i] = ll.Formatter(row[key]) } else { lm.Labels[i] = str(row[key]) } } return lm} // buildModel normalizes the collected chart tree into the model the// chart.js renderer consumes.func buildModel(ctx context.Context, st *chartState) Model { config := configFrom(ctx) if st.kind == "pie" { return buildPieModel(ctx, config, st) } if st.kind == "radar" { return buildRadarModel(ctx, config, st) } if st.kind == "radial" { return buildRadialModel(ctx, config, st) } m := Model{Kind: st.kind, StackOffset: st.stackOffset, Defs: st.defs, Layout: st.layout, AccessibilityLayer: st.accessibilityLayer} if g := st.grid; g != nil { m.Grid = true m.GridHorizontal = boolOr(g.Horizontal, true) m.GridVertical = boolOr(g.Vertical, true) } if y := st.y; y != nil { m.YAxisWidth = y.Width if m.YAxisWidth == 0 { m.YAxisWidth = 60 } m.YAxisMargin = y.TickMargin m.TickCount = y.TickCount m.YTickLine = y.TickLine m.YAxisLine = y.AxisLine m.YAxisHide = y.Hide if y.Hide { m.YAxisWidth = 0 } } if st.margin != nil { m.MarginTop, m.MarginRight, m.MarginBottom, m.MarginLeft = st.margin.Top, st.margin.Right, st.margin.Bottom, st.margin.Left } else { m.MarginTop, m.MarginRight, m.MarginBottom, m.MarginLeft = 5, 5, 5, 5 } if x := st.x; x != nil { m.XAxisHeight = defaultXAxisHeight m.TickMargin = x.TickMargin m.MinTickGap = x.MinTickGap if m.MinTickGap == 0 { m.MinTickGap = 5 } m.XTickLine = x.TickLine m.XAxisLine = x.AxisLine m.XAxisHide = x.Hide if x.Hide { m.XAxisHeight = 0 } } if st.legend != nil { m.LegendHeight = defaultLegendHeight m.LegendVAlign = st.legend.VerticalAlign } tt := st.tooltip if tt != nil { m.Cursor = boolOr(tt.Cursor, true) m.HasTooltip = true m.Tooltip = TooltipModel{Indicator: tt.Content.Indicator, HideLabel: tt.Content.HideLabel, HideIndicator: tt.Content.HideIndicator, Width: tt.Content.Class, LabelClassName: tt.Content.LabelClassName, Color: tt.Content.Color, DefaultIndex: tt.DefaultIndex} if tt.Content.LabelKey != "" { m.Tooltip.Label = config.Label(tt.Content.LabelKey) } } // The category axis carries the labels: XAxis in the default layout, // YAxis when the layout is vertical. dataKey, formatter := "", func(any) string(nil) if st.layout == "vertical" { if y := st.y; y != nil { dataKey, formatter = y.DataKey, y.TickFormatter } } else if x := st.x; x != nil { dataKey, formatter = x.DataKey, x.TickFormatter } m.Labels = make([]string, len(st.data)) m.TooltipLabels = make([]string, len(st.data)) if dataKey != "" { for i, d := range st.data { raw := d[dataKey] if formatter != nil { m.Labels[i] = formatter(raw) } else { m.Labels[i] = str(raw) } if tt != nil && tt.Content.LabelFormatter != nil { m.TooltipLabels[i] = tt.Content.LabelFormatter(raw) } else { m.TooltipLabels[i] = str(raw) } } } if st.kind == "bar" { m.CategoryGap = 0.1 stacked := false for _, bs := range st.bars { b := bs.props s := modelSeries(config, b.DataKey, b.Fill, 0, st.data) s.Radius = radiusCorners(b.Radius) s.StackID = b.StackID s.StrokeWidth = b.StrokeWidth s.ActiveIndex = b.ActiveIndex s.ActiveBar = b.ActiveBar if b.StackID != "" { stacked = true } // Cells set the fill per data row, like the Cell children; a // fill column in the data does the same, Recharts reads it as // the row's own fill. if len(bs.cells) > 0 { s.Cells = make([]string, len(bs.cells)) for i, c := range bs.cells { s.Cells[i] = c.Fill } } else if hasFillColumn(st.data) { s.Cells = make([]string, len(st.data)) for i, row := range st.data { s.Cells[i] = str(row["fill"]) } } for _, ll := range bs.labelLists { s.LabelLists = append(s.LabelLists, labelListModel(ll, b.DataKey, st.data)) } m.Series = append(m.Series, s) } m.Stacked = stacked m.DomainMin = seriesMin(m.Series) } else if st.kind == "line" { for _, l := range st.lines { s := modelSeries(config, l.props.DataKey, "", 0, st.data) s.Curve = string(l.props.Type) s.Stroke = l.props.Stroke s.StrokeWidth = l.props.StrokeWidth if l.props.ActiveDot != nil { s.ActiveDotR = l.props.ActiveDot.R } if d := l.props.Dot; d != nil { dm := &DotModel{R: d.R, Fill: d.Fill, Size: d.Size} if d.DataFill { dm.Fills = make([]string, len(st.data)) for i, row := range st.data { dm.Fills[i] = str(row["fill"]) } } if d.Icon != nil { dm.Icon = renderHTML(ctx, d.Icon) } s.Dot = dm } if ll := l.labelList; ll != nil { lm := labelListModel(*ll, l.props.DataKey, st.data) s.LabelList = &lm } m.Series = append(m.Series, s) } } else { stacked := false for _, a := range st.areas { if a.StackID != "" { stacked = true } s := modelSeries(config, a.DataKey, "", a.FillOpacity, st.data) s.Curve = string(a.Type) s.Fill = a.Fill s.Stroke = a.Stroke m.Series = append(m.Series, s) } m.Stacked = stacked } // nameKey moves the row label to another config entry, like // ChartTooltipContent's nameKey. if tt != nil && tt.Content.NameKey != "" { for i := range m.Series { m.Series[i].Label = config.Label(tt.Content.NameKey) } } // The formatter renders every row of every series up front, the // pendant of the render prop running per tooltip item. if tt != nil && tt.Content.Formatter != nil { f := tt.Content.Formatter m.Tooltip.Rows = make([][]string, len(m.Series)) for si, s := range m.Series { m.Tooltip.Rows[si] = make([]string, len(st.data)) for i, d := range st.data { m.Tooltip.Rows[si][i] = renderHTML(ctx, f(d[s.Key], s.Key, d, si)) } } } applyIcons(ctx, &m, config) return m} // buildRadarModel normalizes a RadarChart into the model.func buildRadarModel(ctx context.Context, config Config, st *chartState) Model { m := Model{Kind: "radar"} if st.margin != nil { m.MarginTop, m.MarginRight, m.MarginBottom, m.MarginLeft = st.margin.Top, st.margin.Right, st.margin.Bottom, st.margin.Left } else { m.MarginTop, m.MarginRight, m.MarginBottom, m.MarginLeft = 5, 5, 5, 5 } polar := PolarModel{RadialLines: true} if g := st.polarGrid; g != nil { polar = polarModel(g) } m.Labels = make([]string, len(st.data)) m.TooltipLabels = make([]string, len(st.data)) if a := st.angleAxis; a != nil { polar.HasAngleAxis = true for i, d := range st.data { m.Labels[i] = str(d[a.DataKey]) m.TooltipLabels[i] = m.Labels[i] if a.Tick != nil { tc := a.Tick(d, i) tm := TickContentModel{FontSize: tc.FontSize, FontWeight: tc.FontWeight, OffsetY: tc.OffsetY} for _, sp := range tc.Spans { tm.Spans = append(tm.Spans, TickSpanModel{Text: sp.Text, Class: sp.Class, FontSize: sp.FontSize, Dy: sp.Dy, ResetX: sp.ResetX}) } polar.Ticks = append(polar.Ticks, tm) } } } m.Polar = &polar for _, r := range st.radars { s := modelSeries(config, r.DataKey, r.Fill, 0, st.data) s.FillOpacityPtr = r.FillOpacity s.Stroke = r.Stroke s.StrokeWidth = r.StrokeWidth if d := r.Dot; d != nil { s.Dot = &DotModel{R: d.R, Fill: d.Fill, FillOpacity: d.FillOpacity} } m.Series = append(m.Series, s) } if st.legend != nil { m.LegendHeight = defaultLegendHeight } if tt := st.tooltip; tt != nil { m.Cursor = boolOr(tt.Cursor, true) m.HasTooltip = true m.Tooltip = TooltipModel{Indicator: tt.Content.Indicator, HideLabel: tt.Content.HideLabel, HideIndicator: tt.Content.HideIndicator, Width: tt.Content.Class, LabelClassName: tt.Content.LabelClassName, Color: tt.Content.Color} } return m} // buildRadialModel normalizes a RadialBarChart: the polar geometry, one// series per radial bar and the label in the middle.func buildRadialModel(ctx context.Context, config Config, st *chartState) Model { m := Model{Kind: "radial"} if st.margin != nil { m.MarginTop, m.MarginRight, m.MarginBottom, m.MarginLeft = st.margin.Top, st.margin.Right, st.margin.Bottom, st.margin.Left } else { m.MarginTop, m.MarginRight, m.MarginBottom, m.MarginLeft = 5, 5, 5, 5 } // The RadialBarChart defaults: a full turn from zero, no inner radius // and an outer radius of 80%. r := RadialModel{StartAngle: st.startAngle, EndAngle: 360, InnerRadius: st.innerRadius, OuterRadius: st.outerRadius} if st.endAngle != nil { r.EndAngle = *st.endAngle } if l := st.center; l != nil { r.Center = labelModel(l) } if g := st.polarGrid; g != nil { polar := polarModel(g) m.Polar = &polar } m.Labels = make([]string, len(st.data)) for i := range st.data { // The radius axis has no data key, so its domain is the row index, // which is what a tooltip label would show. m.Labels[i] = str(i) } tt := st.tooltip for _, rb := range st.radialBars { p := rb.props s := modelSeries(config, p.DataKey, p.Fill, 0, st.data) s.Background = p.Background s.CornerRadius = p.CornerRadius s.StackID = p.StackID s.Class = p.Class // A fill column names the row's own color, like Recharts reading // the fill off the entry. if hasFillColumn(st.data) { s.Cells = make([]string, len(st.data)) for i, row := range st.data { s.Cells[i] = str(row["fill"]) } } // getPayloadConfigFromPayload: the tooltip names every row through // the name key, which the rows answer with their own value. nameKey := p.DataKey if tt != nil && tt.Content.NameKey != "" { nameKey = tt.Content.NameKey } s.TooltipNames = make([]string, len(st.data)) for i, row := range st.data { s.TooltipNames[i] = config.Label(payloadConfigKey(row, nameKey)) } if ll := rb.labelList; ll != nil { lm := labelListModel(*ll, p.DataKey, st.data) s.LabelList = &lm } m.Series = append(m.Series, s) } m.Radial = &r if tt != nil { m.Cursor = boolOr(tt.Cursor, true) m.HasTooltip = true m.Tooltip = TooltipModel{Indicator: tt.Content.Indicator, HideLabel: tt.Content.HideLabel, HideIndicator: tt.Content.HideIndicator, Width: tt.Content.Class, LabelClassName: tt.Content.LabelClassName, Color: tt.Content.Color} } return m} // polarModel resolves a PolarGrid into the model the renderer consumes.func polarModel(g *PolarGridProps) PolarModel { return PolarModel{ HasGrid: true, GridType: g.GridType, RadialLines: boolOr(g.RadialLines, true), PolarRadius: g.PolarRadius, Stroke: g.Stroke, StrokeWidth: g.StrokeWidth, GridClass: g.Class, }} // labelModel resolves a center Label into the model.func labelModel(l *LabelProps) *LabelModel { lm := &LabelModel{DominantBaseline: l.DominantBaseline} for _, sp := range l.Spans { lm.Spans = append(lm.Spans, LabelSpanModel{Text: sp.Text, Class: sp.Class, OffsetY: sp.OffsetY}) } return lm} func buildPieModel(ctx context.Context, config Config, st *chartState) Model { m := Model{Kind: "pie", Cursor: false} if st.legend != nil { m.LegendHeight = defaultLegendHeight } for _, ps := range st.pies { p := ps.props pm := PieModel{ Key: p.DataKey, SeriesLabel: config.Label(p.DataKey), NameKey: p.NameKey, InnerRadius: p.InnerRadius, OuterRadius: p.OuterRadius, StrokeWidth: p.StrokeWidth, Stroke: p.Stroke, ActiveIndex: p.ActiveIndex, ActiveShape: p.ActiveShape, LabelLine: boolOr(p.LabelLine, true), } if p.Label != nil { pm.Label = &PieLabelModel{Fill: p.Label.Fill} } pm.Values = make([]float64, len(p.Data)) pm.Labels = make([]string, len(p.Data)) pm.TooltipNames = make([]string, len(p.Data)) pm.NameValues = make([]string, len(p.Data)) pm.Colors = make([]string, len(p.Data)) for i, d := range p.Data { pm.Values[i] = num(d[p.DataKey]) // getTooltipNameProp: the name key value names the slice, the // data key stands in when the row has none. key := p.DataKey if p.NameKey != "" { if v, ok := d[p.NameKey]; ok && v != nil { key = str(v) } } pm.NameValues[i] = key pm.Labels[i] = config.Label(key) // ChartTooltipContent names the row through nameKey when it is // set, otherwise through the slice name. nameKey := key if st.tooltip != nil && st.tooltip.Content.NameKey != "" { nameKey = st.tooltip.Content.NameKey } pm.TooltipNames[i] = config.Label(payloadConfigKey(d, nameKey)) if f, ok := d["fill"]; ok { pm.Colors[i] = str(f) } else { pm.Colors[i] = seriesColor(key) } } if l := ps.label; l != nil { pm.Center = labelModel(l) } if ll := ps.labelList; ll != nil { // A pie label list reads the name key by default, the slice it // sits on. lm := labelListModel(*ll, p.NameKey, p.Data) pm.LabelList = &lm } m.Pies = append(m.Pies, pm) } if tt := st.tooltip; tt != nil { m.Cursor = boolOr(tt.Cursor, true) m.HasTooltip = true m.Tooltip = TooltipModel{Indicator: tt.Content.Indicator, HideLabel: tt.Content.HideLabel, HideIndicator: tt.Content.HideIndicator, Width: tt.Content.Class, LabelClassName: tt.Content.LabelClassName, Color: tt.Content.Color} if tt.Content.LabelKey != "" { m.Tooltip.Label = config.Label(tt.Content.LabelKey) } } return m} func modelSeries(config Config, key, fill string, fillOpacity float64, data []Datum) ModelSeries { color := fill if color == "" || color == "gradient" { color = seriesColor(key) } values := make([]float64, len(data)) for i, d := range data { values[i] = num(d[key]) } return ModelSeries{Key: key, Label: config.Label(key), Color: color, Values: values, FillOpacity: fillOpacity}} // LegendItem is one rendered legend entry.type LegendItem struct { Label string Color string Icon string // Value is the payload value the legend sorts by. Value string} // payloadConfigKey is getPayloadConfigFromPayload: the key names the// config entry, unless the data row carries a string under that key, then// its value names it.func payloadConfigKey(row Datum, key string) string { if row != nil { if v, ok := row[key]; ok { if sv, isString := v.(string); isString { return sv } } } return key} // legendItems builds the legend payload: one entry per series, or one per// slice for a pie, then sorts by the payload value like Recharts'// itemSorter default of "value". A pie without a name key gives every// entry the same value, so the data order survives the stable sort.func legendItems(config Config, m Model, st *chartState, p *LegendProps) []LegendItem { var out []LegendItem if m.Kind == "pie" { for pi, pie := range m.Pies { rows := st.pies[pi].props.Data for i := range pie.Labels { // The label comes from the legend's name key on the data // row, like getPayloadConfigFromPayload does. name := pie.Labels[i] if p.NameKey != "" && i < len(rows) { if v, ok := rows[i][p.NameKey]; ok && v != nil { name = config.Label(str(v)) } } out = append(out, LegendItem{Label: name, Color: pie.Colors[i], Value: pie.NameValues[i]}) } } } else { for _, s := range m.Series { label := s.Label if p.NameKey != "" { label = config.Label(p.NameKey) } out = append(out, LegendItem{Label: label, Color: s.Color, Icon: s.Icon, Value: s.Key}) } } sort.SliceStable(out, func(i, j int) bool { return out[i].Value < out[j].Value }) return out} // legendContent is the ChartLegendContent pendant, absolutely positioned// like Recharts' legend wrapper. verticalAlign "top" moves the wrapper to// the top edge and swaps the padding side, like cn(verticalAlign === "top"// ? "pb-3" : "pt-3").templ legendContent(items []LegendItem, p *LegendProps) { <div class="recharts-legend-wrapper" if p.VerticalAlign == "top" { style="position:absolute;left:0;right:0;top:5px" } else { style="position:absolute;left:0;right:0;bottom:5px" } > <div class={ utils.CN("flex items-center justify-center gap-4 " + legendPad(p.VerticalAlign), p.Class) }> for _, it := range items { <div class="flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"> if it.Icon != "" && !p.HideIcon { @templ.Raw(it.Icon) } else { <div class="h-2 w-2 shrink-0 rounded-[2px]" style={ "background-color:" + it.Color }></div> } { it.Label } </div> } </div> </div>} // legendPad is the padding side of the legend box, away from the plot.func legendPad(verticalAlign string) string { if verticalAlign == "top" { return "pb-3" } return "pt-3"} // Model describes one rendered chart for chart.js: the client runtime// re-renders the SVG at real container pixels (Recharts'// ResponsiveContainer behavior) and drives tooltip and cursor from it.type Model struct { Kind string `json:"kind"` // "bar" | "area" | "pie" MarginTop float64 `json:"marginTop"` MarginRight float64 `json:"marginRight"` MarginBottom float64 `json:"marginBottom"` MarginLeft float64 `json:"marginLeft"` XAxisHeight float64 `json:"xAxisHeight,omitempty"` TickMargin float64 `json:"tickMargin,omitempty"` MinTickGap float64 `json:"minTickGap,omitempty"` YAxisWidth float64 `json:"yAxisWidth,omitempty"` YAxisMargin float64 `json:"yAxisMargin,omitempty"` // tickMargin of the y axis TickCount int `json:"tickCount,omitempty"` // y ticks, Recharts default 5 XTickLine bool `json:"xTickLine,omitempty"` XAxisLine bool `json:"xAxisLine,omitempty"` YTickLine bool `json:"yTickLine,omitempty"` YAxisLine bool `json:"yAxisLine,omitempty"` LegendHeight float64 `json:"legendHeight,omitempty"` LegendVAlign string `json:"legendVAlign,omitempty"` // "top" raises the legend above the plot CategoryGap float64 `json:"categoryGap,omitempty"` Radius float64 `json:"radius,omitempty"` Grid bool `json:"grid,omitempty"` GridHorizontal bool `json:"gridHorizontal,omitempty"` GridVertical bool `json:"gridVertical,omitempty"` // Layout "vertical" swaps the axes and draws the bars horizontally. Layout string `json:"layout,omitempty"` XAxisHide bool `json:"xAxisHide,omitempty"` YAxisHide bool `json:"yAxisHide,omitempty"` DomainMin float64 `json:"domainMin,omitempty"` // negative values extend the domain Stacked bool `json:"stacked,omitempty"` StackOffset string `json:"stackOffset,omitempty"` // "expand" normalizes each stack to 1 Defs []LinearGradientProps `json:"defs,omitempty"` Cursor bool `json:"cursor"` // HasTooltip marks a declared Tooltip child: without one Recharts // renders no tooltip at all, so the runtime skips the hover wiring. HasTooltip bool `json:"hasTooltip,omitempty"` Labels []string `json:"labels"` TooltipLabels []string `json:"tooltipLabels,omitempty"` SliceColors []string `json:"sliceColors,omitempty"` // pie: color per slice Series []ModelSeries `json:"series"` Tooltip TooltipModel `json:"tooltip"` // Pies carries the pie geometry for the client renderer. Pies []PieModel `json:"pies,omitempty"` // Polar carries the radar geometry. Polar *PolarModel `json:"polar,omitempty"` // Radial carries the RadialBarChart geometry. Radial *RadialModel `json:"radial,omitempty"` // AccessibilityLayer switches on the keyboard layer, Recharts' // accessibilityLayer prop. AccessibilityLayer bool `json:"accessibilityLayer,omitempty"`} // RadialModel is the geometry of a RadialBarChart.type RadialModel struct { StartAngle float64 `json:"startAngle"` EndAngle float64 `json:"endAngle"` InnerRadius float64 `json:"innerRadius,omitempty"` OuterRadius float64 `json:"outerRadius,omitempty"` Center *LabelModel `json:"center,omitempty"`} // LabelModel is the text in the middle of a donut or radial chart.type LabelModel struct { Spans []LabelSpanModel `json:"spans"` DominantBaseline string `json:"dominantBaseline,omitempty"`} // LabelSpanModel is one tspan of a center label.type LabelSpanModel struct { Text string `json:"text"` Class string `json:"class,omitempty"` OffsetY float64 `json:"offsetY,omitempty"`} // TickContentModel is a custom axis tick for the client renderer.type TickContentModel struct { Spans []TickSpanModel `json:"spans"` FontSize float64 `json:"fontSize,omitempty"` FontWeight string `json:"fontWeight,omitempty"` OffsetY float64 `json:"offsetY,omitempty"`} // TickSpanModel is one tspan of a custom tick.type TickSpanModel struct { Text string `json:"text"` Class string `json:"class,omitempty"` FontSize float64 `json:"fontSize,omitempty"` Dy string `json:"dy,omitempty"` ResetX bool `json:"resetX,omitempty"`} // PolarModel is the grid and axis setup of a polar chart.type PolarModel struct { GridType string `json:"gridType,omitempty"` RadialLines bool `json:"radialLines"` PolarRadius []float64 `json:"polarRadius,omitempty"` Stroke string `json:"stroke,omitempty"` StrokeWidth float64 `json:"strokeWidth,omitempty"` GridClass string `json:"gridClass,omitempty"` Ticks []TickContentModel `json:"ticks,omitempty"` HasGrid bool `json:"hasGrid,omitempty"` HasAngleAxis bool `json:"hasAngleAxis,omitempty"`} // PieModel is one rendered Pie with its sectors.type PieModel struct { Key string `json:"key"` SeriesLabel string `json:"seriesLabel,omitempty"` Values []float64 `json:"values"` Labels []string `json:"labels"` TooltipNames []string `json:"tooltipNames,omitempty"` NameKey string `json:"nameKey,omitempty"` NameValues []string `json:"nameValues,omitempty"` Colors []string `json:"colors"` InnerRadius float64 `json:"innerRadius,omitempty"` OuterRadius float64 `json:"outerRadius,omitempty"` StrokeWidth float64 `json:"strokeWidth,omitempty"` Stroke string `json:"stroke,omitempty"` Label *PieLabelModel `json:"label,omitempty"` LabelLine bool `json:"labelLine,omitempty"` LabelList *LabelListModel `json:"labelList,omitempty"` ActiveIndex *int `json:"activeIndex,omitempty"` ActiveShape []SectorProps `json:"activeShape,omitempty"` Center *LabelModel `json:"center,omitempty"`} // PieLabelModel is the resolved label prop of a Pie.type PieLabelModel struct { Fill string `json:"fill,omitempty"`} // ModelSeries is one data series with its resolved color variable.type ModelSeries struct { Key string `json:"key"` Label string `json:"label"` Color string `json:"color"` Values []float64 `json:"values"` FillOpacity float64 `json:"fillOpacity,omitempty"` // areas: 0 uses Recharts' 0.6 Curve string `json:"curve,omitempty"` // "natural" (default), "linear", "step", "monotone" FillOpacityPtr *float64 `json:"fillOpacityPtr,omitempty"` // radar: an explicit zero stays Icon string `json:"icon,omitempty"` // rendered svg, replaces the tooltip indicator Fill string `json:"fill,omitempty"` // verbatim fill, e.g. url(#fillDesktop) Stroke string `json:"stroke,omitempty"` // verbatim stroke for the area line Radius []float64 `json:"radius,omitempty"` // bars: corner radii, one or four StackID string `json:"stackId,omitempty"` Cells []string `json:"cells,omitempty"` // bars: fill per data row ActiveIndex *int `json:"activeIndex,omitempty"` ActiveBar *RectangleProps `json:"activeBar,omitempty"` LabelLists []LabelListModel `json:"labelLists,omitempty"` // bars: one or more label lists StrokeWidth float64 `json:"strokeWidth,omitempty"` // lines: stroke width Dot *DotModel `json:"dot,omitempty"` // lines: per point dots ActiveDotR float64 `json:"activeDotR,omitempty"` // lines: hover dot radius LabelList *LabelListModel `json:"labelList,omitempty"` // lines: value labels // radial bars: the track behind the bar, its corner radius and the // class the source puts on the sectors Background bool `json:"background,omitempty"` CornerRadius float64 `json:"cornerRadius,omitempty"` Class string `json:"class,omitempty"` TooltipNames []string `json:"tooltipNames,omitempty"` // name per row, through the name key} // DotModel describes the per point dots of a line.type DotModel struct { R float64 `json:"r,omitempty"` FillOpacity float64 `json:"fillOpacity,omitempty"` Fill string `json:"fill,omitempty"` Fills []string `json:"fills,omitempty"` // per point fills from the data rows Icon string `json:"icon,omitempty"` // rendered svg replacing the dot Size float64 `json:"size,omitempty"` // icon box size} // LabelListModel carries the precomputed labels of a LabelList.type LabelListModel struct { Position string `json:"position,omitempty"` Offset float64 `json:"offset,omitempty"` FontSize float64 `json:"fontSize,omitempty"` FillOpacity float64 `json:"fillOpacity,omitempty"` Class string `json:"class,omitempty"` Labels []string `json:"labels"`} // TooltipModel mirrors ChartTooltipContent's props.type TooltipModel struct { Indicator string `json:"indicator,omitempty"` // "dot" (default) | "line" | "dashed" Label string `json:"label,omitempty"` // labelKey resolved through the config HideLabel bool `json:"hideLabel,omitempty"` HideIndicator bool `json:"hideIndicator,omitempty"` Width string `json:"width,omitempty"` // extra class, e.g. "w-[150px]" LabelClassName string `json:"labelClass,omitempty"` // extra label class, labelClassName Color string `json:"color,omitempty"` // indicator color override // DefaultIndex shows the tooltip on mount at that category. DefaultIndex *int `json:"defaultIndex,omitempty"` // Rows is the formatter markup per series and data row. Rows [][]string `json:"rows,omitempty"`} // ModelScript renders the embedded JSON payload chart.js reads.func ModelScript(m Model) string { b, err := json.Marshal(m) if err != nil { return "" } return `<script type="application/json" data-tui-chart-model>` + string(b) + `</script>`}/** * shadcn-templ chart - client renderer. * * The server emits the chart model as JSON; this module is the pendant of * Recharts in the browser and draws everything. It is a literal port of * the parts of the reference libraries the chart components need: * - recharts ResponsiveContainer: render at the container's real pixel * size via ResizeObserver, so bars, radii and text keep their sizes. * - recharts-scale getNiceTickValues: the y domain and tick values. * - recharts CartesianAxis preserveEnd: tick culling with measured label * sizes and minTickGap. * - d3-shape: curveNatural, curveLinear, curveStep and stackOffsetExpand. * - d3-array ticks: the grid angles of a radial chart, whose angle axis * has no tick count and so falls back to d3's default ten. * - recharts Sector: the sector path and its rounded corners, plus * getBarPosition and computeRadialBarDataItems for the radial rings. * - react-smooth: entrance and update animations with the CSS ease bezier, * the from state paints synchronously on mount and the clock starts on * the first real frame. * - shadcn ChartTooltipContent and ChartStyle: tooltip markup, classes and * the per chart color variables. * Optional model fields carry Recharts' prop defaults at the JSON boundary * (xAxisHeight 0, minTickGap 5, tickCount 5, innerRadius 0). */ const TOOLTIP_CLASS = "cn-chart-tooltip grid min-w-32 items-start"; /* ---------------------------------------------------------------- *//* Geometry (ports of the Go engine) *//* ---------------------------------------------------------------- */ function fmtF(v) { return String(Math.round(v * 1000) / 1000);} function getDigitCount(v) { if (v === 0) return 1; return Math.floor(Math.log10(Math.abs(v))) + 1;} function adaptiveStep(rough, correction) { if (rough <= 0) return 0; const digitCount = getDigitCount(rough); const digitCountValue = Math.pow(10, digitCount); const stepRatio = rough / digitCountValue; const scale = digitCount !== 1 ? 0.05 : 0.1; return (Math.ceil(stepRatio / scale) + correction) * scale * digitCountValue;} /* recharts-scale getFormatStep: the step between two ticks that reads * well (10, 20, 25). */function getFormatStep(roughStep, allowDecimals, correctionFactor) { if (roughStep <= 0) return 0; const digitCount = getDigitCount(roughStep); const digitCountValue = Math.pow(10, digitCount); const stepRatio = roughStep / digitCountValue; const stepRatioScale = digitCount !== 1 ? 0.05 : 0.1; const amendStepRatio = (Math.ceil(stepRatio / stepRatioScale) + correctionFactor) * stepRatioScale; const formatStep = amendStepRatio * digitCountValue; return allowDecimals ? formatStep : Math.ceil(formatStep);} /* recharts-scale calculateStep: zero is always a tick when the interval * contains it, and the step grows until the ticks cover the interval. */function calculateStep(min, max, tickCount, allowDecimals, correctionFactor = 0) { if (!Number.isFinite((max - min) / (tickCount - 1))) { return { step: 0, tickMin: 0, tickMax: 0 }; } const step = getFormatStep((max - min) / (tickCount - 1), allowDecimals, correctionFactor); let middle; if (min <= 0 && max >= 0) { middle = 0; } else { middle = (min + max) / 2; middle = middle - (middle % step); } let belowCount = Math.ceil((middle - min) / step); let upCount = Math.ceil((max - middle) / step); const scaleCount = belowCount + upCount + 1; if (scaleCount > tickCount) { return calculateStep(min, max, tickCount, allowDecimals, correctionFactor + 1); } if (scaleCount < tickCount) { upCount = max > 0 ? upCount + (tickCount - scaleCount) : upCount; belowCount = max > 0 ? belowCount : belowCount + (tickCount - scaleCount); } return { step, tickMin: middle - belowCount * step, tickMax: middle + upCount * step };} /* recharts-scale getNiceTickValues: the tick values of an interval, with * the count guaranteed. */function niceTickValues(min, max, tickCount = 6, allowDecimals = true) { const count = Math.max(tickCount, 2); if (min === max) return [min]; const { step, tickMin, tickMax } = calculateStep(min, max, count, allowDecimals); if (step <= 0) return [0]; const values = []; for (let v = tickMin; v <= tickMax + 0.1 * step; v += step) values.push(v); return values;} function linearY(v, dmax, top, height) { if (dmax === 0) return top + height; return top + height - (v / dmax) * height;} /* expandValues normalizes stacked values per index to a total of 1 * (d3-shape stackOffsetExpand behind Recharts' "expand"). */function expandValues(series) { const n = series[0].values.length; const vals = series.map(() => new Array(n).fill(0)); for (let i = 0; i < n; i++) { let sum = 0; for (const s of series) sum += s.values[i]; if (sum > 0) { series.forEach((s, si) => { vals[si][i] = s.values[i] / sum; }); } } return vals;} /* domainTicks computes a model's nice tick values (stacked aware); the * domain spans from the first to the last tick, like Recharts. */function domainTicks(m, tickCount = 5) { if (m.stackOffset === "expand") { return Array.from({ length: tickCount }, (_, i) => i / (tickCount - 1)); } let max = 0; let min = 0; const n = m.series[0].values.length; if (m.stacked) { for (let i = 0; i < n; i++) { let sum = 0; for (const s of m.series) sum += s.values[i]; if (sum > max) max = sum; if (sum < min) min = sum; } } else { for (const s of m.series) { for (const v of s.values) { if (v > max) max = v; if (v < min) min = v; } } } return niceTickValues(min, max, tickCount);} /* domainOf is the top of the value domain, used to pin the scale during a * morph. */function domainOf(m) { const t = domainTicks(m); return t[t.length - 1];} /* valueScale maps a value onto its pixel position. With negative values * the domain spans [min, max] and the zero baseline sits inside the plot, * like Recharts' linear scale. */function valueScale(m, start, length, ticks) { const max = ticks[ticks.length - 1]; const min = ticks[0]; const span = max - min || 1; return { max, min, // pos returns the pixel of a value along the axis from start. pos: (v) => start + length - ((v - min) / span) * length, zero: start + length - ((0 - min) / span) * length, };} function barPositions(band, categoryGap, count) { const gap = categoryGap * band; let realBarGap = 4; if (band - 2 * gap - (count - 1) * realBarGap <= 0) realBarGap = 0; let size = (band - 2 * gap - (count - 1) * realBarGap) / count; if (size > 1) size = Math.round(size); const offsets = []; for (let i = 0; i < count; i++) offsets.push(gap + (size + realBarGap) * i); return [offsets, size];} /* getRectanglePath from Recharts' Rectangle: the radius is one value for * all corners or four values starting at the top left, clamped to half the * rectangle. */function roundedBarPath(x, y, w, h, radius) { const corners = Array.isArray(radius) ? radius : [radius || 0]; const [tl, tr, br, bl] = corners.length === 4 ? corners : [corners[0] || 0, corners[0] || 0, corners[0] || 0, corners[0] || 0]; const max = Math.min(Math.abs(w) / 2, Math.abs(h) / 2); const c = [tl, tr, br, bl].map((v) => Math.max(0, Math.min(v, max))); if (c.every((v) => v === 0)) { return `M ${fmtF(x)},${fmtF(y)} h ${fmtF(w)} v ${fmtF(h)} h ${fmtF(-w)} Z`; } return ( `M ${fmtF(x)},${fmtF(y + c[0])} a ${fmtF(c[0])},${fmtF(c[0])} 0 0 1 ${fmtF(c[0])},${fmtF(-c[0])} ` + `h ${fmtF(w - c[0] - c[1])} a ${fmtF(c[1])},${fmtF(c[1])} 0 0 1 ${fmtF(c[1])},${fmtF(c[1])} ` + `v ${fmtF(h - c[1] - c[2])} a ${fmtF(c[2])},${fmtF(c[2])} 0 0 1 ${fmtF(-c[2])},${fmtF(c[2])} ` + `h ${fmtF(-(w - c[2] - c[3]))} a ${fmtF(c[3])},${fmtF(c[3])} 0 0 1 ${fmtF(-c[3])},${fmtF(-c[3])} Z` );} function naturalControls(x) { const n = x.length - 1; const a = new Array(n); const b = new Array(n); const r = new Array(n); a[0] = 0; b[0] = 2; r[0] = x[0] + 2 * x[1]; for (let i = 1; i < n - 1; i++) { a[i] = 1; b[i] = 4; r[i] = 4 * x[i] + 2 * x[i + 1]; } a[n - 1] = 2; b[n - 1] = 7; r[n - 1] = 8 * x[n - 1] + x[n]; for (let i = 1; i < n; i++) { const m = a[i] / b[i - 1]; b[i] -= m; r[i] -= m * r[i - 1]; } a[n - 1] = r[n - 1] / b[n - 1]; for (let i = n - 2; i >= 0; i--) { a[i] = (r[i] - a[i + 1]) / b[i]; } b[n - 1] = (x[n] + a[n - 1]) / 2; for (let i = 0; i < n - 1; i++) { b[i] = 2 * x[i + 1] - a[i + 1]; } return [a, b];} function naturalPath(xs, ys) { if (xs.length < 2) return ""; const [cx1, cx2] = naturalControls(xs); const [cy1, cy2] = naturalControls(ys); let d = `M${fmtF(xs[0])},${fmtF(ys[0])}`; for (let i = 0; i < xs.length - 1; i++) { d += `C${fmtF(cx1[i])},${fmtF(cy1[i])},${fmtF(cx2[i])},${fmtF(cy2[i])},${fmtF(xs[i + 1])},${fmtF(ys[i + 1])}`; } return d;} /* d3-shape curveLinear. */function linearPath(xs, ys) { if (xs.length < 2) return ""; let d = `M${fmtF(xs[0])},${fmtF(ys[0])}`; for (let i = 1; i < xs.length; i++) { d += `L${fmtF(xs[i])},${fmtF(ys[i])}`; } return d;} /* d3-shape curveStep (t = 0.5): y switches at the midpoint, ends on the * last point. */function stepPath(xs, ys) { if (xs.length < 2) return ""; let d = `M${fmtF(xs[0])},${fmtF(ys[0])}`; for (let i = 1; i < xs.length; i++) { const x1 = (xs[i - 1] + xs[i]) / 2; d += `L${fmtF(x1)},${fmtF(ys[i - 1])}L${fmtF(x1)},${fmtF(ys[i])}`; } d += `L${fmtF(xs[xs.length - 1])},${fmtF(ys[ys.length - 1])}`; return d;} /* d3-shape curveMonotoneX (Steffen 1990 monotone Hermite interpolation), * Recharts' type="monotone". */function monotonePath(xs, ys) { if (xs.length < 2) return ""; const sign = (x) => (x < 0 ? -1 : 1); const slope3 = (x0, y0, x1, y1, x2, y2) => { const h0 = x1 - x0; const h1 = x2 - x1; const s0 = (y1 - y0) / (h0 || (h1 < 0 && -0)); const s1 = (y2 - y1) / (h1 || (h0 < 0 && -0)); const p = (s0 * h1 + s1 * h0) / (h0 + h1); return (sign(s0) + sign(s1)) * Math.min(Math.abs(s0), Math.abs(s1), 0.5 * Math.abs(p)) || 0; }; const slope2 = (x0, y0, x1, y1, t) => { const h = x1 - x0; return h ? (3 * (y1 - y0) / h - t) / 2 : t; }; const bezier = (x0, y0, x1, y1, t0, t1) => { const dx = (x1 - x0) / 3; return `C${fmtF(x0 + dx)},${fmtF(y0 + dx * t0)},${fmtF(x1 - dx)},${fmtF(y1 - dx * t1)},${fmtF(x1)},${fmtF(y1)}`; }; let d = `M${fmtF(xs[0])},${fmtF(ys[0])}`; let t0 = NaN; for (let i = 1; i < xs.length; i++) { let t1; if (i < xs.length - 1) { t1 = slope3(xs[i - 1], ys[i - 1], xs[i], ys[i], xs[i + 1], ys[i + 1]); } else { t1 = slope2(xs[i - 1], ys[i - 1], xs[i], ys[i], t0); } if (i === 1) t0 = slope2(xs[0], ys[0], xs[1], ys[1], t1); d += bezier(xs[i - 1], ys[i - 1], xs[i], ys[i], t0, t1); t0 = t1; } return d;} function curvePath(curve, xs, ys) { if (curve === "linear") return linearPath(xs, ys); if (curve === "step") return stepPath(xs, ys); if (curve === "monotone") return monotonePath(xs, ys); return naturalPath(xs, ys);} /* Recharts' Text verticalAnchor as an SVG dy: the anchor names where the * text box sits relative to y. */function verticalAnchorDy(anchor) { if (anchor === "start") return "0.71em"; if (anchor === "middle") return "0.355em"; return "0";} /* pathLength measures a path string like Recharts measures the rendered * curve for the line entrance (strokeDasharray animation). */let lengthPath = null;function pathLength(d) { if (!lengthPath) { lengthPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); } lengthPath.setAttribute("d", d); return lengthPath.getTotalLength();} function areaPathBetween(curve, xs, ysTop, ysBase) { const rx = [...xs].reverse(); const rb = [...ysBase].reverse(); const base = curvePath(curve, rx, rb); return curvePath(curve, xs, ysTop) + "L" + base.slice(1) + "Z";} /* preserveEnd tick culling with real text measurement, like Recharts. */let measureCtx = null;function measureLabel(label, refEl) { if (!measureCtx) { measureCtx = document.createElement("canvas").getContext("2d"); } const cs = getComputedStyle(refEl); measureCtx.font = `${cs.fontSize} ${cs.fontFamily}`; return measureCtx.measureText(label).width;} /* The label height Recharts reads from the DOM: 1.5 times the font size, * matching the measured 18px at 12px text. */function measureLabelHeight(refEl) { return parseFloat(getComputedStyle(refEl).fontSize) * 1.5;} function preserveEndTicks(coords, sizes, start, end, minTickGap) { let sign = 1; if (coords.length >= 2 && coords[1] < coords[0]) sign = -1; const kept = []; for (let i = coords.length - 1; i >= 0; i--) { const size = sizes[i]; let tickCoord = coords[i]; if (i === coords.length - 1) { const gap = sign * (tickCoord + (sign * size) / 2 - end); if (gap > 0) tickCoord -= gap * sign; } if (sign * tickCoord < sign * start || sign * tickCoord > sign * end) continue; if (sign * (tickCoord - (sign * size) / 2 - start) >= 0 && sign * (tickCoord + (sign * size) / 2 - end) <= 0) { end = tickCoord - sign * (size / 2 + minTickGap); kept.push({ index: i, coord: tickCoord }); } } return kept.reverse();} /* Recharts' CartesianAxis tickSize: the tick line length, and part of the * label offset even when the line is hidden. */const TICK_SIZE = 6; /* ---------------------------------------------------------------- *//* Rendering *//* ---------------------------------------------------------------- */ let uid = 0; /* swapSVG applies a newly built SVG to the panel. While the structure is * unchanged (animation frames) it only syncs attributes and text in * place, like React's reconciliation in Recharts, so frames never churn * DOM nodes. */function swapSVG(panel, svgString) { const old = panel.querySelector("svg.recharts-surface"); if (!old) { panel.insertAdjacentHTML("beforeend", svgString); return; } // Hover overlays (cursor band, active dots) are transient additions, // drop them before comparing so animation frames keep the fast path. old.querySelectorAll(".recharts-tooltip-cursor").forEach((c) => c.parentElement.remove()); old.querySelectorAll(".recharts-active-dots").forEach((d) => d.remove()); const tpl = document.createElement("template"); tpl.innerHTML = svgString; const next = tpl.content.firstElementChild; const a = old.querySelectorAll("*"); const b = next.querySelectorAll("*"); if (a.length !== b.length) { old.replaceWith(next); return; } syncAttrs(old, next); for (let i = 0; i < a.length; i++) { if (a[i].tagName !== b[i].tagName) { old.replaceWith(next); return; } syncAttrs(a[i], b[i]); if (b[i].childElementCount === 0 && a[i].textContent !== b[i].textContent) { a[i].textContent = b[i].textContent; } }} function syncAttrs(el, src) { for (const attr of src.attributes) { if (el.getAttribute(attr.name) !== attr.value) { el.setAttribute(attr.name, attr.value); } }} /* CSS 'ease' (cubic-bezier(0.25, 0.1, 0.25, 1)), Recharts' default * animation easing. */function cssEase(t) { if (t <= 0) return 0; if (t >= 1) return 1; let lo = 0; let hi = 1; // Solve x(u) = t for u, then return y(u). const bx = (u) => 3 * u * (1 - u) * (1 - u) * 0.25 + 3 * u * u * (1 - u) * 0.25 + u * u * u; const by = (u) => 3 * u * (1 - u) * (1 - u) * 0.1 + 3 * u * u * (1 - u) * 1 + u * u * u; for (let i = 0; i < 20; i++) { const mid = (lo + hi) / 2; if (bx(mid) < t) lo = mid; else hi = mid; } return by((lo + hi) / 2);} const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); /* The Recharts entrance animation: bars grow from the baseline over * 400ms, areas reveal left to right and pies sweep over 1500ms. * Like react-smooth, the from state paints synchronously on mount (axes * and grid are visible immediately) and the clock starts on the first * real frame, so charts in throttled frames play the full entrance the * moment frames arrive instead of getting stuck blank. */function animateChart(panel, m, state, render) { if (reducedMotion.matches) { render(1); return; } const duration = m.kind === "bar" ? 400 : 1500; render(0); let beginTime; const frame = (now) => { if (!beginTime) beginTime = now; const alpha = cssEase(Math.min(1, (now - beginTime) / duration)); render(alpha); if (alpha < 1) requestAnimationFrame(frame); }; requestAnimationFrame(frame);} /* The Recharts update animation: every graphical item interpolates its * previous points into the new ones in pixel space, index mapped by * prevPointsDiffFactor when the point count changed. */function morphChart(panel, m, state, render, prevPoints) { if (reducedMotion.matches) { render(1); return; } const duration = m.kind === "bar" ? 400 : 1500; const paint = (t) => { state.morph = t < 1 ? { prev: prevPoints, t } : null; render(1); }; // Like the entrance: the previous state paints synchronously, the clock // starts on the first real frame. paint(0); let beginTime; const frame = (now) => { if (!beginTime) beginTime = now; const t = cssEase(Math.min(1, (now - beginTime) / duration)); paint(t); if (t < 1) requestAnimationFrame(frame); }; requestAnimationFrame(frame);} /* interpolate of Recharts' DataUtils. */function interpolate(start, end, t) { if (start == null) return end; return start + (end - start) * t;} /* morphPoints maps a series' points onto the previous ones the way * Recharts does: prevPointsDiffFactor picks the previous index. */function morphPoints(morph, si, coords, key) { if (!morph || !morph.prev || !morph.prev[key] || !morph.prev[key][si]) return coords; const prev = morph.prev[key][si]; const factor = prev.length / coords.length; return coords.map((v, i) => { const p = prev[Math.floor(i * factor)]; return p == null ? v : interpolate(p, v, morph.t); });} function renderCartesian(panel, m, state, alpha = 1) { const W = panel.clientWidth; const H = panel.clientHeight; if (!W || !H) return; const legendHeight = legendSize(panel, m); const vertical = m.layout === "vertical"; // selectChartOffsetInternal: the axes add to the margins, the legend box // adds to the bottom (or the top with verticalAlign "top", Recharts' // appendOffsetOfLegend), and the plot never goes negative. const yAxisW = m.yAxisWidth || 0; const plotX = m.marginLeft + yAxisW; const plotY = m.marginTop + (m.legendVAlign === "top" ? legendHeight : 0); const plotW = Math.max(W - m.marginLeft - m.marginRight - yAxisW, 0); const plotH = Math.max(H - m.marginTop - m.marginBottom - (m.xAxisHeight || 0) - legendHeight, 0); const plotBottom = plotY + plotH; const n = m.labels.length; // During a morph the scale is pinned to the target domain like // Recharts, which interpolates pixel positions on the new scale. const tickCount = m.tickCount || 5; const ticks = m.domainMax ? Array.from({ length: tickCount }, (_, i) => (m.domainMax * i) / (tickCount - 1)) : domainTicks(m, tickCount); const domainMin = ticks[0]; const domainMax = ticks[ticks.length - 1]; // Category positions: band centers for bars, evenly spaced points for // lines and areas. const catStart = vertical ? plotY : plotX; const catLength = vertical ? plotH : plotW; const bandSize = m.kind === "bar" ? catLength / n : 0; const cats = []; for (let i = 0; i < n; i++) { cats.push(m.kind === "bar" ? catStart + i * bandSize + bandSize / 2 : plotX + (i * plotW) / (n - 1)); } // Explicit pixel size like Recharts' Surface: the svg never stretches // between resize frames, every frame lays out fresh. The accessibility // layer puts Recharts' role and tabIndex on the surface; it is on unless // the chart opted out, Recharts' accessibilityLayer !== false. const a11y = m.accessibilityLayer !== false ? ` role="application" tabindex="0"` : ""; let svg = `<svg class="recharts-surface"${a11y} width="${fmtF(W)}" height="${fmtF(H)}" viewBox="0 0 ${fmtF(W)} ${fmtF(H)}">`; if (m.defs && m.defs.length) { svg += "<defs>"; for (const g of m.defs) { // The stops are raw markup from the demo, passed through verbatim // like Recharts passes defs children through. svg += `<linearGradient id="${state.uid}-${g.ID}" x1="${g.X1}" y1="${g.Y1}" x2="${g.X2}" y2="${g.Y2}">${g.Stops || ""}</linearGradient>`; } svg += "</defs>"; } if (yAxisW > 0 && vertical) { // Vertical layout: the y axis carries the category labels. const ySizes = cats.map(() => measureLabelHeight(panel)); const labelX = plotX - TICK_SIZE - (m.yAxisMargin || 0); svg += `<g class="recharts-layer recharts-cartesian-axis recharts-yAxis yAxis"><g class="recharts-cartesian-axis-ticks">`; // The category coordinates ascend downwards, so the bounds run from // the top of the surface to its bottom. for (const tk of preserveEndTicks(cats, ySizes, 0, H, m.minTickGap || 5)) { svg += `<g class="recharts-layer recharts-cartesian-axis-tick"><text orientation="left" width="${fmtF(yAxisW)}" x="${fmtF(labelX)}" y="${fmtF(tk.coord)}" stroke="none" fill="#666" class="recharts-text recharts-cartesian-axis-tick-value" text-anchor="end"><tspan dy="0.355em">${m.labels[tk.index]}</tspan></text></g>`; } svg += "</g></g>"; } else if (yAxisW > 0) { const yCoords = ticks.map((tv) => linearY(tv - domainMin, domainMax - domainMin, plotY, plotH)); const ySizes = ticks.map(() => measureLabelHeight(panel)); const labelX = plotX - TICK_SIZE - (m.yAxisMargin || 0); svg += `<g class="recharts-layer recharts-cartesian-axis recharts-yAxis yAxis">`; if (m.yAxisLine) { svg += `<line orientation="left" class="recharts-cartesian-axis-line" stroke="#666" fill="none" x1="${fmtF(plotX)}" y1="${fmtF(plotY)}" x2="${fmtF(plotX)}" y2="${fmtF(plotBottom)}"/>`; } svg += `<g class="recharts-cartesian-axis-ticks">`; for (const tk of preserveEndTicks(yCoords, ySizes, H, 0, m.minTickGap || 5)) { svg += `<g class="recharts-layer recharts-cartesian-axis-tick">`; if (m.yTickLine) { svg += `<line orientation="left" class="recharts-cartesian-axis-tick-line" stroke="#666" fill="none" x1="${fmtF(plotX - TICK_SIZE)}" y1="${fmtF(yCoords[tk.index])}" x2="${fmtF(plotX)}" y2="${fmtF(yCoords[tk.index])}"/>`; } svg += `<text orientation="left" width="${fmtF(yAxisW)}" x="${fmtF(labelX)}" y="${fmtF(tk.coord)}" stroke="none" fill="#666" class="recharts-text recharts-cartesian-axis-tick-value" text-anchor="end"><tspan dy="0.355em">${fmtF(ticks[tk.index])}</tspan></text></g>`; } svg += "</g></g>"; } if (m.grid) { // Recharts draws a line per tick of the value axis and per category of // the other one; both directions default to on. const valueCoords = ticks.map((tv) => { const p = linearY(tv - domainMin, domainMax - domainMin, vertical ? plotX : plotY, vertical ? plotW : plotH); return vertical ? plotX + plotW - (p - plotX) : p; }); svg += `<g class="recharts-layer recharts-cartesian-grid">`; if (m.gridHorizontal) { svg += `<g class="recharts-cartesian-grid-horizontal">`; for (const y of vertical ? cats : valueCoords) { svg += `<line stroke="#ccc" fill="none" x1="${fmtF(plotX)}" y1="${fmtF(y)}" x2="${fmtF(plotX + plotW)}" y2="${fmtF(y)}"/>`; } svg += "</g>"; } if (m.gridVertical) { svg += `<g class="recharts-cartesian-grid-vertical">`; for (const x of vertical ? valueCoords : cats) { svg += `<line stroke="#ccc" fill="none" x1="${fmtF(x)}" y1="${fmtF(plotY)}" x2="${fmtF(x)}" y2="${fmtF(plotBottom)}"/>`; } svg += "</g>"; } svg += "</g>"; } let xs = []; let band = 0; // The update-animation sources, Recharts' prevPoints/prevData: full // point geometry per series (xs and tops for curves, the signed // rectangles for bars, the numeric base line for areas). state.points = { tops: [], xs: [], rects: [], base: plotBottom }; const vals = m.stackOffset === "expand" ? expandValues(m.series) : m.series.map((s) => s.values); if (m.kind === "bar") { // The category axis runs along x, or along y when the layout is // vertical and the bars grow to the right. band = bandSize; // Stacked bars share one slot per category, like Recharts' stackId. const slots = m.stacked ? 1 : m.series.length; const [offsets, barSize] = barPositions(band, m.categoryGap, slots); const scale = valueScale(m, vertical ? plotX : plotY, vertical ? plotW : plotH, ticks); // In a vertical layout the value axis grows from left to right, so the // scale is mirrored around the plot. const valuePos = (v) => (vertical ? plotX + plotW - (scale.pos(v) - plotX) : scale.pos(v)); const zero = vertical ? plotX + plotW - (scale.zero - plotX) : scale.zero; const stackBase = new Array(n).fill(0); state.tops = []; for (let si = 0; si < m.series.length; si++) { const s = m.series[si]; const slot = m.stacked ? 0 : si; const tops = []; const rects = []; // Recharts' Bar geometry keeps the rectangle signed: the origin is // the value end and the size runs back to the baseline, so negative // bars carry a negative size. Label positions read those signs. const rectPos = []; const rectSize = []; svg += `<g class="recharts-layer recharts-bar"><g class="recharts-layer recharts-bar-rectangles">`; for (let i = 0; i < n; i++) { const raw = vals[si][i]; const from = m.stacked ? stackBase[i] : 0; const to = m.stacked ? stackBase[i] + raw : raw; const base = valuePos(from); const end = valuePos(to); const cat = catStart + i * band + offsets[slot]; // The signed rectangle like Recharts computes it: the origin sits // at the value end and the size runs back to the baseline. let rect = vertical ? { x: base, y: cat, width: end - base, height: barSize } : { x: cat, y: end, width: barSize, height: base - end }; // renderRectanglesWithAnimation: with a previous rectangle at the // same index all four sides interpolate; without one the bar plays // the entrance at the animation clock instead. if (state.morph) { const prevRects = state.morph.prev.rects && state.morph.prev.rects[si]; const prev = prevRects && prevRects[i]; const t = state.morph.t; if (prev) { rect = { x: interpolate(prev.x, rect.x, t), y: interpolate(prev.y, rect.y, t), width: interpolate(prev.width, rect.width, t), height: interpolate(prev.height, rect.height, t), }; } else if (vertical) { rect.width = rect.width * t; } else { const h = rect.height * t; rect = { ...rect, y: rect.y + rect.height - h, height: h }; } } // The entrance grows the height from the baseline, vertical the // width, Recharts' no-prev branch driven by the entrance clock. if (alpha < 1) { if (vertical) { rect.width = rect.width * alpha; } else { const h = rect.height * alpha; rect = { ...rect, y: rect.y + rect.height - h, height: h }; } } const fill = (s.cells && s.cells[i]) || s.color; const active = s.activeIndex != null && s.activeIndex === i && s.activeBar; let attrs = `fill="${fill}"`; if (active) { const ab = s.activeBar; attrs += ` fill-opacity="${fmtF(ab.FillOpacity || 1)}" stroke="${ab.Stroke || fill}"`; if (ab.StrokeDasharray) attrs += ` stroke-dasharray="${fmtF(ab.StrokeDasharray)}"`; if (ab.StrokeDashoffset) attrs += ` stroke-dashoffset="${fmtF(ab.StrokeDashoffset)}"`; if (s.strokeWidth) attrs += ` stroke-width="${fmtF(s.strokeWidth)}"`; } let d; if (vertical) { d = roundedBarPath(Math.min(rect.x, rect.x + rect.width), rect.y, Math.abs(rect.width), rect.height, s.radius); } else { d = roundedBarPath(rect.x, Math.min(rect.y, rect.y + rect.height), rect.width, Math.abs(rect.height), s.radius); } tops.push(vertical ? rect.x + rect.width : rect.y); rectPos.push(vertical ? rect.x : rect.y); rectSize.push(vertical ? rect.width : rect.height); rects.push(rect); svg += `<g class="recharts-layer recharts-bar-rectangle"><path class="recharts-rectangle" ${attrs} d="${d}"/></g>`; if (m.stacked) stackBase[i] = to; } svg += "</g>"; // Label lists paint after the entrance and after an update morph, // like Recharts gates them on isAnimationFinished. if (alpha >= 1 && !state.morph && s.labelLists) { for (const ll of s.labelLists) { svg += `<g class="recharts-layer recharts-label-list">`; for (let i = 0; i < n; i++) { const catCenter = catStart + i * band + offsets[slot] + barSize / 2; const offset = ll.offset || 5; // getAttrsOfCartesianLabel: the sign of the rectangle flips the // offset and the anchor, so a negative bar labels below its end. let x, y, anchor, vAnchor; if (vertical) { const width = rectSize[i]; const sign = width >= 0 ? 1 : -1; y = catCenter; vAnchor = "middle"; anchor = sign > 0 ? "start" : "end"; x = ll.position === "right" ? rectPos[i] + width + sign * offset : rectPos[i] + sign * offset; } else { const height = rectSize[i]; const sign = height >= 0 ? 1 : -1; x = catCenter; y = rectPos[i] - sign * offset; anchor = "middle"; vAnchor = sign > 0 ? "end" : "start"; } const fo = ll.fillOpacity ? ` fill-opacity="${fmtF(ll.fillOpacity)}"` : ""; // LabelList inherits the entry's presentation props, so a label // without its own class takes the bar's fill. const fill = ll.class ? "" : ` fill="${(s.cells && s.cells[i]) || s.color}"`; svg += `<text x="${fmtF(x)}" y="${fmtF(y)}" class="recharts-text recharts-label ${ll.class || ""}" text-anchor="${anchor}" font-size="${fmtF(ll.fontSize || 12)}"${fill}${fo}><tspan dy="${verticalAnchorDy(vAnchor)}">${ll.labels[i]}</tspan></text>`; } svg += `</g>`; } } svg += "</g>"; state.tops.push(tops); state.points.rects.push(rects); } xs = cats; } else if (m.kind === "line") { xs = cats; state.tops = []; for (let si = 0; si < m.series.length; si++) { const s = m.series[si]; // stepPoints of the update animation: x and y both interpolate from // the previous point picked by prevPointsDiffFactor. const sx = morphPoints(state.morph, si, cats.slice(), "xs"); const top = morphPoints(state.morph, si, vals[si].map((v) => linearY(v - domainMin, domainMax - domainMin, plotY, plotH)), "tops"); const d = curvePath(s.curve, sx, top); // The Recharts line entrance: strokeDasharray sweeps the measured // curve length from 0 to totalLength. let dash = ""; if (alpha < 1) { const total = pathLength(d); dash = ` stroke-dasharray="${fmtF(total * alpha)}px ${fmtF(total - total * alpha)}px"`; } svg += `<g class="recharts-layer recharts-line">` + `<path class="recharts-curve recharts-line-curve" stroke="${s.stroke || s.color}" stroke-width="${s.strokeWidth || 1}" fill="none"${dash} d="${d}"/>`; // Dots and labels appear when the entrance and the update morph // finished, like Recharts' isAnimationFinished gate on renderDots // and LabelList. if (alpha >= 1 && !state.morph && s.dot) { svg += `<g class="recharts-layer recharts-line-dots">`; for (let i = 0; i < n; i++) { if (s.dot.icon) { const size = s.dot.size || 24; svg += `<g transform="translate(${fmtF(sx[i] - size / 2)},${fmtF(top[i] - size / 2)})">${s.dot.icon}</g>`; } else { const fill = (s.dot.fills && s.dot.fills[i]) || s.dot.fill || "#fff"; const stroke = (s.dot.fills && s.dot.fills[i]) || s.stroke || s.color; // A dot from the data fill is the demos' explicit Dot element, // which carries no strokeWidth, so the SVG default of one wins; // a plain dot inherits the line's strokeWidth like Recharts // spreading the line props into renderDots. const dotStrokeWidth = s.dot.fills ? 1 : s.strokeWidth || 1; svg += `<circle r="${fmtF(s.dot.r || 3)}" stroke="${stroke}" stroke-width="${fmtF(dotStrokeWidth)}" fill="${fill}" class="recharts-dot recharts-line-dot" cx="${fmtF(sx[i])}" cy="${fmtF(top[i])}"/>`; } } svg += `</g>`; } if (alpha >= 1 && !state.morph && s.labelList) { const ll = s.labelList; svg += `<g class="recharts-layer recharts-label-list">`; for (let i = 0; i < n; i++) { const fill = ll.class ? "" : ` fill="${s.stroke || s.color}"`; svg += `<text x="${fmtF(sx[i])}" y="${fmtF(top[i] - (ll.offset || 5))}" class="recharts-text recharts-label ${ll.class || ""}" text-anchor="middle" font-size="${fmtF(ll.fontSize || 12)}"${fill}><tspan>${ll.labels[i]}</tspan></text>`; } svg += `</g>`; } svg += `</g>`; state.tops.push(top); state.points.xs.push(sx); } } else { xs = cats; // Recharts' entrance animation reveals areas left to right through a // clipPath rect (AreaRevealShape). A numeric base line interpolates // during the update morph like stepBaseLine. const baseY = state.morph && state.morph.prev.base != null ? interpolate(state.morph.prev.base, plotBottom, state.morph.t) : plotBottom; const baseline = new Array(n).fill(baseY); let base = baseline; state.tops = []; for (let si = 0; si < m.series.length; si++) { const s = m.series[si]; // stepPoints of the update animation: x and y both interpolate from // the previous point picked by prevPointsDiffFactor. const sx = morphPoints(state.morph, si, cats.slice(), "xs"); const top = morphPoints( state.morph, si, m.stacked ? base.map((b, i) => b - ((vals[si][i] / (domainMax - domainMin)) * plotH || 0)) : vals[si].map((v) => linearY(v - domainMin, domainMax - domainMin, plotY, plotH)), "tops" ); const fill = (s.fill || "").replace("url(#", `url(#${state.uid}-`) || s.color; const fillOpacity = s.fillOpacity || 0.6; const areaD = m.stacked ? areaPathBetween(s.curve, sx, top, base) : areaPathBetween(s.curve, sx, top, baseline); // HorizontalRect: the reveal spans the point range and reaches the // lowest painted y plus the stroke width. let clip = ""; let clipOpen = ""; if (alpha < 1) { const width = alpha * Math.abs(sx[0] - sx[n - 1]); const maxY = Math.max(...top, ...(m.stacked ? base : baseline)); const id = `${state.uid}-reveal-${si}`; clip = `<defs><clipPath id="${id}"><rect x="${fmtF(sx[0] < sx[n - 1] ? sx[0] : sx[0] - width)}" y="0" width="${fmtF(width)}" height="${fmtF(Math.floor(maxY + 1))}"/></clipPath></defs>`; clipOpen = ` clip-path="url(#${id})"`; } svg += clip + `<g class="recharts-layer recharts-area"${clipOpen}>` + `<path class="recharts-curve recharts-area-area" fill="${fill}" fill-opacity="${fillOpacity}" stroke="none" d="${areaD}"/>` + `<path class="recharts-curve recharts-area-curve" stroke="${s.stroke || s.color}" fill="none" stroke-width="1" d="${curvePath(s.curve, sx, top)}"/>` + `</g>`; state.tops.push(top); state.points.xs.push(sx); if (m.stacked) base = top; } } // The x axis only renders when the chart declared a visible one. if (m.xAxisHeight && !vertical) { const widths = m.labels.map((l) => measureLabel(l, panel)); const labelY = plotBottom + TICK_SIZE + (m.tickMargin || 0); svg += `<g class="recharts-layer recharts-cartesian-axis recharts-xAxis xAxis">`; if (m.xAxisLine) { svg += `<line orientation="bottom" class="recharts-cartesian-axis-line" stroke="#666" fill="none" x1="${fmtF(plotX)}" y1="${fmtF(plotBottom)}" x2="${fmtF(plotX + plotW)}" y2="${fmtF(plotBottom)}"/>`; } svg += `<g class="recharts-cartesian-axis-ticks">`; for (const tk of preserveEndTicks(xs, widths, 0, W, m.minTickGap || 5)) { svg += `<g class="recharts-layer recharts-cartesian-axis-tick">`; if (m.xTickLine) { svg += `<line orientation="bottom" class="recharts-cartesian-axis-tick-line" stroke="#666" fill="none" x1="${fmtF(xs[tk.index])}" y1="${fmtF(plotBottom + TICK_SIZE)}" x2="${fmtF(xs[tk.index])}" y2="${fmtF(plotBottom)}"/>`; } svg += `<text orientation="bottom" height="${fmtF(m.xAxisHeight)}" x="${fmtF(tk.coord)}" y="${fmtF(labelY)}" stroke="none" fill="#666" class="recharts-text recharts-cartesian-axis-tick-value" text-anchor="middle"><tspan dy="0.71em">${m.labels[tk.index]}</tspan></text></g>`; } svg += "</g></g>"; } svg += "</svg>"; swapSVG(panel, svg); state.points.tops = state.tops || []; state.geom = { W, H, plotX, plotY, plotW, plotH, plotBottom, band, xs, cats, n, vertical };} /* Pie sector path, the port of the Go SectorPath (degrees, 0 at three * o'clock, counterclockwise positive). *//* useElementOffset and setLegendSize: the legend reports its rendered * bounding box, and appendOffsetOfLegend takes that height off the chart. * The model height is the fallback until the legend has been laid out. */function legendSize(panel, m) { if (!m.legendHeight) return 0; const el = panel.querySelector(".recharts-legend-wrapper"); if (el) { const h = el.getBoundingClientRect().height; if (h > 0) return h; } return m.legendHeight;} /* polarToCartesian of Recharts: degrees, zero at three o'clock, positive * counterclockwise. */function polarToCartesian(cx, cy, radius, angle) { const rad = (angle * Math.PI) / 180; return { x: cx + Math.cos(rad) * radius, y: cy - Math.sin(rad) * radius };} /* getAngleOfPoint of Recharts' PolarUtils: the polar coordinates of a * point around the chart center. */function angleOfPoint(x, y, cx, cy) { const radius = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2); if (radius <= 0) return { radius, angle: 0 }; let angleInRadian = Math.acos((x - cx) / radius); if (y > cy) angleInRadian = 2 * Math.PI - angleInRadian; return { radius, angle: (angleInRadian * 180) / Math.PI };} /* calculateActiveTickIndex for an angle axis that spans a full circle: * the pointer belongs to the tick whose half intervals contain it. */function activeAngleIndex(coordinate, ticks) { const len = ticks.length; if (len <= 1) return 0; const range = [90, -270]; for (let i = 0; i < len; i++) { const before = i > 0 ? ticks[i - 1] : ticks[len - 1]; const cur = ticks[i]; const after = i >= len - 1 ? ticks[0] : ticks[i + 1]; const sign = (v) => (v > 0 ? 1 : v < 0 ? -1 : 0); let sameDirectionCoord; if (sign(cur - before) !== sign(after - cur)) { const diffInterval = []; if (sign(after - cur) === sign(range[1] - range[0])) { sameDirectionCoord = after; const curInRange = cur + range[1] - range[0]; diffInterval[0] = Math.min(curInRange, (curInRange + before) / 2); diffInterval[1] = Math.max(curInRange, (curInRange + before) / 2); } else { sameDirectionCoord = before; const afterInRange = after + range[1] - range[0]; diffInterval[0] = Math.min(cur, (afterInRange + cur) / 2); diffInterval[1] = Math.max(cur, (afterInRange + cur) / 2); } const sameInterval = [Math.min(cur, (sameDirectionCoord + cur) / 2), Math.max(cur, (sameDirectionCoord + cur) / 2)]; if ((coordinate > sameInterval[0] && coordinate <= sameInterval[1]) || (coordinate >= diffInterval[0] && coordinate <= diffInterval[1])) { return i; } } else { const minValue = Math.min(before, after); const maxValue = Math.max(before, after); if (coordinate > (minValue + cur) / 2 && coordinate <= (maxValue + cur) / 2) { return i; } } } return -1;} /* formatAngleOfSector plus the angle test of inRangeOfSector. */function inAngleRangeOfSector(angle, startAngle, endAngle) { const min = Math.min(Math.floor(startAngle / 360), Math.floor(endAngle / 360)); const start = startAngle - min * 360; const end = endAngle - min * 360; let formatAngle = angle; if (start <= end) { while (formatAngle > end) formatAngle -= 360; while (formatAngle < start) formatAngle += 360; return formatAngle >= start && formatAngle <= end; } while (formatAngle > start) formatAngle -= 360; while (formatAngle < end) formatAngle += 360; return formatAngle >= end && formatAngle <= start;} /* calculateActiveTickIndex for ticks that run in a single direction. */function activeTickIndex(coordinate, ticks) { const len = ticks.length; if (len <= 1) return 0; for (let i = 0; i < len; i++) { if ( (i === 0 && coordinate <= (ticks[i] + ticks[i + 1]) / 2) || (i > 0 && i < len - 1 && coordinate > (ticks[i] + ticks[i - 1]) / 2 && coordinate <= (ticks[i] + ticks[i + 1]) / 2) || (i === len - 1 && coordinate > (ticks[i] + ticks[i - 1]) / 2) ) { return i; } } return -1;} /* getPolygonPath of Recharts' PolarGrid. */function polarPolygonPath(radius, cx, cy, angles) { let path = ""; angles.forEach((angle, i) => { const p = polarToCartesian(cx, cy, radius, angle); path += (i ? "L " : "M ") + fmtF(p.x) + "," + fmtF(p.y); }); return path + "Z";} /* PolarGrid: ConcentricCircle and ConcentricPolygon carry the radius twice * like Recharts does, PolarAngles runs the radial lines from the inner to * the outer radius. The stroke defaults to Recharts' "#ccc", the demos * that paint their rings through a class pass "none" instead. */function polarGridSVG(polar, cx, cy, innerRadius, outerRadius, radii, angles) { const stroke = polar.stroke || "#ccc"; const strokeWidth = polar.strokeWidth ? ` stroke-width="${fmtF(polar.strokeWidth)}"` : ""; const cls = polar.gridClass ? " " + polar.gridClass : ""; let svg = `<g class="recharts-polar-grid"><g class="recharts-polar-grid-concentric">`; for (const r of radii) { if (polar.gridType === "circle") { svg += `<circle class="recharts-polar-grid-concentric-circle${cls}" stroke="${stroke}" fill="none"${strokeWidth} cx="${fmtF(cx)}" cy="${fmtF(cy)}" radius="${fmtF(r)}" r="${fmtF(r)}"/>`; } else { svg += `<path class="recharts-polar-grid-concentric-polygon${cls}" stroke="${stroke}" fill="none"${strokeWidth} d="${polarPolygonPath(r, cx, cy, angles)}"/>`; } } svg += `</g>`; if (polar.radialLines) { svg += `<g class="recharts-polar-grid-angle">`; for (const a of angles) { const start = polarToCartesian(cx, cy, innerRadius, a); const end = polarToCartesian(cx, cy, outerRadius, a); svg += `<line stroke="${stroke}"${strokeWidth} x1="${fmtF(start.x)}" y1="${fmtF(start.y)}" x2="${fmtF(end.x)}" y2="${fmtF(end.y)}"/>`; } svg += `</g>`; } return svg + `</g>`;} /* The Label content of the demos: a text in the middle of the chart with * one tspan per line, each at its own offset off the center. */function centerLabelSVG(cx, cy, label) { const baseline = label.dominantBaseline ? ` dominant-baseline="${label.dominantBaseline}"` : ""; let svg = `<text x="${fmtF(cx)}" y="${fmtF(cy)}" text-anchor="middle"${baseline}>`; for (const span of label.spans || []) { const cls = span.class ? ` class="${span.class}"` : ""; svg += `<tspan x="${fmtF(cx)}" y="${fmtF(cy + (span.offsetY || 0))}"${cls}>${span.text}</tspan>`; } return svg + `</text>`;} /* renderRadar draws a RadarChart: the polar grid, the angle axis labels and * one polygon per series. The geometry follows RadarChart's defaults, a * start angle of 90 running to -270 and an outer radius of 80%. */function renderRadar(panel, m, state, alpha = 1) { const W = panel.clientWidth; const H = panel.clientHeight; if (!W || !H) return; const legendHeight = legendSize(panel, m); const offsetW = Math.max(W - m.marginLeft - m.marginRight, 0); const offsetH = Math.max(H - m.marginTop - m.marginBottom - legendHeight, 0); const cx = m.marginLeft + offsetW / 2; const cy = m.marginTop + offsetH / 2; const maxRadius = Math.min(offsetW, offsetH) / 2; const outerRadius = maxRadius * 0.8; const n = m.series.length ? m.series[0].values.length : 0; if (!n) return; // The angle axis is a band scale from 90 to -270, one tick per row. const step = -360 / n; const angles = Array.from({ length: n }, (_, i) => 90 + step * i); const ticks = domainTicks(m, m.tickCount || 5); const domainMax = ticks[ticks.length - 1]; const polar = m.polar || {}; const radii = polar.polarRadius && polar.polarRadius.length ? polar.polarRadius : ticks.map((t) => (t / domainMax) * outerRadius); let svg = `<svg class="recharts-surface" width="${fmtF(W)}" height="${fmtF(H)}" viewBox="0 0 ${fmtF(W)} ${fmtF(H)}">`; if (polar.hasGrid) { svg += polarGridSVG(polar, cx, cy, 0, outerRadius, radii, angles); } if (polar.hasAngleAxis) { // getTickLineCoord with the default tickSize of 8 and the outer // orientation, plus getTickTextAnchor and getTickTextVerticalAnchor. const eps = 1e-5; const COS_45 = Math.cos((45 * Math.PI) / 180); svg += `<g class="recharts-polar-angle-axis recharts-axis">`; // AxisLine: a polygon through the ticks at the outer radius, the // default axisLineType of "polygon". svg += `<path class="recharts-polygon recharts-polar-angle-axis-line" fill="none" d="${polarPolygonPath(outerRadius, cx, cy, angles)}"/>`; svg += `<g class="recharts-polar-angle-axis-ticks">`; for (let i = 0; i < n; i++) { const coord = angles[i]; const p = polarToCartesian(cx, cy, outerRadius + 8, coord); const cos = Math.cos((-coord * Math.PI) / 180); const sin = Math.sin((-coord * Math.PI) / 180); const anchor = cos > eps ? "start" : cos < -eps ? "end" : "middle"; const vAnchor = Math.abs(cos) <= COS_45 ? (sin > 0 ? "start" : "end") : "middle"; // getTickLineCoord: from the axis out by the default tickSize of 8. const p1 = polarToCartesian(cx, cy, outerRadius, coord); svg += `<line class="recharts-polar-angle-axis-tick-line" fill="none" x1="${fmtF(p1.x)}" y1="${fmtF(p1.y)}" x2="${fmtF(p.x)}" y2="${fmtF(p.y)}"/>`; const custom = polar.ticks && polar.ticks[i]; if (custom) { const y = p.y + (custom.offsetY || 0); const fs = custom.fontSize ? ` font-size="${fmtF(custom.fontSize)}"` : ""; const fw = custom.fontWeight ? ` font-weight="${custom.fontWeight}"` : ""; svg += `<text class="recharts-text recharts-polar-angle-axis-tick-value" x="${fmtF(p.x)}" y="${fmtF(y)}" text-anchor="${anchor}"${fs}${fw} fill="currentColor">`; for (const span of custom.spans) { const attrs = (span.resetX ? ` x="${fmtF(p.x)}"` : "") + (span.dy ? ` dy="${span.dy}"` : "") + (span.fontSize ? ` font-size="${fmtF(span.fontSize)}"` : "") + (span.class ? ` class="${span.class}"` : ""); svg += `<tspan${attrs}>${span.text}</tspan>`; } svg += `</text>`; } else { svg += `<text class="recharts-text recharts-polar-angle-axis-tick-value" x="${fmtF(p.x)}" y="${fmtF(p.y)}" text-anchor="${anchor}" fill="#666"><tspan dy="${verticalAnchorDy(vAnchor)}">${m.labels[i]}</tspan></text>`; } } svg += `</g></g>`; } state.tops = []; state.points = { radar: [] }; const morph = state.morph; for (let si = 0; si < m.series.length; si++) { const s = m.series[si]; // interpolatePolarPoint: the points grow out of the center on mount and // interpolate from the previous points on an update. const prev = morph && morph.prev && morph.prev.radar ? morph.prev.radar[si] : null; const factor = prev ? prev.length / s.values.length : 0; const pts = s.values.map((v, i) => { const target = polarToCartesian(cx, cy, (v / domainMax) * outerRadius, angles[i]); if (morph) { const p = prev && prev[Math.floor(i * factor)]; if (p) return { x: interpolate(p.x, target.x, morph.t), y: interpolate(p.y, target.y, morph.t) }; return { x: interpolate(cx, target.x, morph.t), y: interpolate(cy, target.y, morph.t) }; } if (alpha < 1) { return { x: interpolate(cx, target.x, alpha), y: interpolate(cy, target.y, alpha) }; } return target; }); const d = pts.map((p, i) => (i ? "L" : "M") + fmtF(p.x) + "," + fmtF(p.y)).join("") + "Z"; // filterProps only forwards the props that are set, so an unset fill // opacity leaves the SVG default of 1 in place. const fillOpacity = s.fillOpacityPtr != null ? ` fill-opacity="${fmtF(s.fillOpacityPtr)}"` : ""; const stroke = s.stroke ? ` stroke="${s.stroke}"` : ""; const strokeWidth = s.strokeWidth ? ` stroke-width="${fmtF(s.strokeWidth)}"` : ""; svg += `<g class="recharts-layer recharts-radar">` + `<g class="recharts-radar-polygon">` + `<path class="recharts-polygon" fill="${s.fill || s.color}"${fillOpacity}${stroke}${strokeWidth} d="${d}"/>`; // StaticPolygon renders the dots together with the polygon, so they // travel with it during the animation. if (s.dot) { svg += `<g class="recharts-radar-dots">`; for (const p of pts) { const dotOpacity = s.dot.fillOpacity ? ` fill-opacity="${fmtF(s.dot.fillOpacity)}"` : ""; svg += `<circle class="recharts-dot recharts-radar-dot" r="${fmtF(s.dot.r || 3)}" fill="${s.dot.fill || s.fill || s.color}"${dotOpacity}${stroke} cx="${fmtF(p.x)}" cy="${fmtF(p.y)}"/>`; } svg += `</g>`; } svg += `</g></g>`; state.tops.push(pts.map((p) => p.y)); state.points.radar.push(pts); } svg += "</svg>"; swapSVG(panel, svg); state.geom = { W, H, cx, cy, outerRadius, angles, n };} /* tickSpec and ticks of d3-array: the angle axis of a radial chart has no * tick count of its own, so its grid angles are d3's default ten. */function d3TickSpec(start, stop, count) { const e10 = Math.sqrt(50); const e5 = Math.sqrt(10); const e2 = Math.sqrt(2); const step = (stop - start) / Math.max(0, count); const power = Math.floor(Math.log10(step)); const error = step / Math.pow(10, power); const factor = error >= e10 ? 10 : error >= e5 ? 5 : error >= e2 ? 2 : 1; let i1, i2, inc; if (power < 0) { inc = Math.pow(10, -power) / factor; i1 = Math.round(start * inc); i2 = Math.round(stop * inc); if (i1 / inc < start) ++i1; if (i2 / inc > stop) --i2; inc = -inc; } else { inc = Math.pow(10, power) * factor; i1 = Math.round(start / inc); i2 = Math.round(stop / inc); if (i1 * inc < start) ++i1; if (i2 * inc > stop) --i2; } if (i2 < i1 && 0.5 <= count && count < 2) return d3TickSpec(start, stop, count * 2); return [i1, i2, inc];} function d3Ticks(start, stop, count) { if (!(count > 0)) return []; if (start === stop) return [start]; const reverse = stop < start; const [i1, i2, inc] = reverse ? d3TickSpec(stop, start, count) : d3TickSpec(start, stop, count); if (!(i2 >= i1)) return []; const n = i2 - i1 + 1; const out = new Array(n); if (reverse) { if (inc < 0) for (let i = 0; i < n; ++i) out[i] = (i2 - i) / -inc; else for (let i = 0; i < n; ++i) out[i] = (i2 - i) * inc; } else { if (inc < 0) for (let i = 0; i < n; ++i) out[i] = (i1 + i) / -inc; else for (let i = 0; i < n; ++i) out[i] = (i1 + i) * inc; } return out;} /* getBarPosition of Recharts for the chart defaults, a bar gap of 4 and a * bar category gap of 10% of the band. The size is truncated to whole * pixels, which is Recharts' own ">>= 0". */function barPosition(bandSize, len) { let realBarGap = 4; const offset = 0.1 * bandSize; if (bandSize - 2 * offset - (len - 1) * realBarGap <= 0) realBarGap = 0; let originalSize = (bandSize - 2 * offset - (len - 1) * realBarGap) / len; if (originalSize > 1) originalSize = Math.trunc(originalSize); return { offset, size: originalSize, gap: realBarGap };} /* truncateByDomain of Recharts: a stacked range is clamped into the number * axis domain on both ends. */function truncateByDomain(value, domainMin, domainMax) { const minValue = Math.min(domainMin, domainMax); const maxValue = Math.max(domainMin, domainMax); const result = [value[0], value[1]]; if (value[0] < minValue) result[0] = minValue; if (value[1] > maxValue) result[1] = maxValue; if (result[0] > maxValue) result[0] = maxValue; if (result[1] < minValue) result[1] = minValue; return result;} /* getTangentCircle of Recharts' Sector: the circle a rounded corner runs * on, with the two points it is tangent to. */function tangentCircle({ cx, cy, radius, angle, sign, isExternal, cornerRadius, cornerIsExternal }) { const RADIAN = Math.PI / 180; const centerRadius = cornerRadius * (isExternal ? 1 : -1) + radius; const theta = Math.asin(cornerRadius / centerRadius) / RADIAN; const centerAngle = cornerIsExternal ? angle : angle + sign * theta; const center = polarToCartesian(cx, cy, centerRadius, centerAngle); const circleTangency = polarToCartesian(cx, cy, radius, centerAngle); const lineTangencyAngle = cornerIsExternal ? angle - sign * theta : angle; const lineTangency = polarToCartesian(cx, cy, centerRadius * Math.cos(theta * RADIAN), lineTangencyAngle); return { center, circleTangency, lineTangency, theta };} /* getSectorWithCorner of Recharts' Sector. */function sectorWithCornerPath(cx, cy, innerR, outerR, cornerRadius, startAngle, endAngle) { const sign = endAngle - startAngle < 0 ? -1 : endAngle - startAngle > 0 ? 1 : 0; const { circleTangency: soct, lineTangency: solt, theta: sot } = tangentCircle({ cx, cy, radius: outerR, angle: startAngle, sign, cornerRadius }); const { circleTangency: eoct, lineTangency: eolt, theta: eot } = tangentCircle({ cx, cy, radius: outerR, angle: endAngle, sign: -sign, cornerRadius }); const outerArcAngle = Math.abs(startAngle - endAngle) - sot - eot; if (outerArcAngle < 0) { return sectorPath(cx, cy, innerR, outerR, startAngle, endAngle); } let path = `M ${fmtF(solt.x)},${fmtF(solt.y)} A${fmtF(cornerRadius)},${fmtF(cornerRadius)},0,0,${sign < 0 ? 1 : 0},${fmtF(soct.x)},${fmtF(soct.y)}` + ` A${fmtF(outerR)},${fmtF(outerR)},0,${outerArcAngle > 180 ? 1 : 0},${sign < 0 ? 1 : 0},${fmtF(eoct.x)},${fmtF(eoct.y)}` + ` A${fmtF(cornerRadius)},${fmtF(cornerRadius)},0,0,${sign < 0 ? 1 : 0},${fmtF(eolt.x)},${fmtF(eolt.y)}`; if (innerR > 0) { const { circleTangency: sict, lineTangency: silt, theta: sit } = tangentCircle({ cx, cy, radius: innerR, angle: startAngle, sign, isExternal: true, cornerRadius }); const { circleTangency: eict, lineTangency: eilt, theta: eit } = tangentCircle({ cx, cy, radius: innerR, angle: endAngle, sign: -sign, isExternal: true, cornerRadius }); const innerArcAngle = Math.abs(startAngle - endAngle) - sit - eit; if (innerArcAngle < 0 && cornerRadius === 0) { return `${path}L${fmtF(cx)},${fmtF(cy)}Z`; } path += `L${fmtF(eilt.x)},${fmtF(eilt.y)} A${fmtF(cornerRadius)},${fmtF(cornerRadius)},0,0,${sign < 0 ? 1 : 0},${fmtF(eict.x)},${fmtF(eict.y)}` + ` A${fmtF(innerR)},${fmtF(innerR)},0,${innerArcAngle > 180 ? 1 : 0},${sign > 0 ? 1 : 0},${fmtF(sict.x)},${fmtF(sict.y)}` + ` A${fmtF(cornerRadius)},${fmtF(cornerRadius)},0,0,${sign < 0 ? 1 : 0},${fmtF(silt.x)},${fmtF(silt.y)}Z`; } else { path += `L${fmtF(cx)},${fmtF(cy)}Z`; } return path;} /* Sector of Recharts: a corner radius bends the ends, and it is capped at * half the ring width. */function sector(cx, cy, innerR, outerR, startAngle, endAngle, cornerRadius) { if (outerR < innerR || startAngle === endAngle) return ""; const deltaRadius = outerR - innerR; const cr = cornerRadius || 0; if (cr > 0 && Math.abs(startAngle - endAngle) < 360) { return sectorWithCornerPath(cx, cy, innerR, outerR, Math.min(cr, deltaRadius / 2), startAngle, endAngle); } return sectorPath(cx, cy, innerR, outerR, startAngle, endAngle);} /* getSectorPath of Recharts' Sector. */function sectorPath(cx, cy, innerR, outerR, startAngle, endAngle) { // getDeltaAngle stops just short of a full turn, so a sector that goes // all the way around still has two distinct arc ends and paints. const sign = endAngle - startAngle < 0 ? -1 : 1; const angle = Math.min(Math.abs(endAngle - startAngle), 359.999) * sign; const tempEndAngle = startAngle + angle; const outerStart = polarToCartesian(cx, cy, outerR, startAngle); const outerEnd = polarToCartesian(cx, cy, outerR, tempEndAngle); const large = Math.abs(angle) > 180 ? 1 : 0; let path = `M ${fmtF(outerStart.x)},${fmtF(outerStart.y)} A ${fmtF(outerR)},${fmtF(outerR)},0,${large},` + `${startAngle > tempEndAngle ? 1 : 0},${fmtF(outerEnd.x)},${fmtF(outerEnd.y)}`; if (innerR > 0) { const innerStart = polarToCartesian(cx, cy, innerR, startAngle); const innerEnd = polarToCartesian(cx, cy, innerR, tempEndAngle); path += `L ${fmtF(innerEnd.x)},${fmtF(innerEnd.y)} A ${fmtF(innerR)},${fmtF(innerR)},0,${large},` + `${startAngle <= tempEndAngle ? 1 : 0},${fmtF(innerStart.x)},${fmtF(innerStart.y)} Z`; } else { path += `L ${fmtF(cx)},${fmtF(cy)} Z`; } return path;} /* getTextAnchor of Pie's labels. */function pieTextAnchor(x, cx) { if (x > cx) return "start"; if (x < cx) return "end"; return "middle";} const PIE_LABEL_OFFSET = 20; function renderPie(panel, m, state, alpha = 1) { const W = panel.clientWidth; const H = panel.clientHeight; if (!W || !H) return; // The Pie defaults: centered in the offset box, 80% of its radius. The // legend adds to the bottom offset, so it shrinks the pie instead of // covering it. const margin = 5; const offsetW = Math.max(W - 2 * margin, 0); const offsetH = Math.max(H - 2 * margin - legendSize(panel, m), 0); const cx = margin + offsetW / 2; const cy = margin + offsetH / 2; const maxR = Math.min(offsetW, offsetH) / 2; let svg = `<svg class="recharts-surface" width="${fmtF(W)}" height="${fmtF(H)}" viewBox="0 0 ${fmtF(W)} ${fmtF(H)}">`; state.points = { sectors: [] }; (m.pies || []).forEach((pie, pi) => { const outerR = pie.outerRadius || maxR * 0.8; const innerR = pie.innerRadius || 0; const total = pie.values.reduce((a, b) => a + b, 0); const stroke = pie.stroke === "0" ? "" : ` stroke="${pie.stroke || "#fff"}"`; const strokeWidth = pie.strokeWidth ? ` stroke-width="${fmtF(pie.strokeWidth)}"` : ""; const sectors = []; // The Pie animation: every sector interpolates its own angle and they // chain. On mount it starts at zero, on an update it starts at the // previous sector's angle. const morph = state.morph; const prevSectors = morph && morph.prev && morph.prev.sectors ? morph.prev.sectors[pi] : null; let angle = 0; for (let i = 0; i < pie.values.length; i++) { const target = (pie.values[i] / total) * 360 * alpha; let sweep = target; if (prevSectors && prevSectors[i]) { sweep = interpolate(prevSectors[i].end - prevSectors[i].start, target, morph.t); } sectors.push({ start: angle, end: angle + sweep, mid: angle + sweep / 2 }); angle += sweep; } svg += `<g class="recharts-layer recharts-pie">`; for (let i = 0; i < sectors.length; i++) { const sc = sectors[i]; const fill = pie.colors[i]; const active = pie.activeIndex != null && pie.activeIndex === i && pie.activeShape && pie.activeShape.length; const shapes = active ? pie.activeShape.map((sp) => ({ inner: sp.InnerRadius ? outerR + sp.InnerRadius : innerR, outer: outerR + (sp.OuterRadius || 0) })) : [{ inner: innerR, outer: outerR }]; svg += `<g class="recharts-layer recharts-pie-sector">`; for (const sh of shapes) { svg += `<path class="recharts-sector"${stroke}${strokeWidth} fill="${fill}" data-tui-chart-pie="${pi}" data-tui-chart-sector="${i}" d="${sectorPath(cx, cy, sh.inner, sh.outer, sc.start, sc.end)}"/>`; } svg += `</g>`; } // PieLabels: the label sits offsetRadius past the sector, the line // runs from the sector edge to it. if (alpha >= 1 && pie.label) { svg += `<g class="recharts-layer recharts-pie-labels">`; for (let i = 0; i < sectors.length; i++) { const mid = sectors[i].mid; const endPoint = polarToCartesian(cx, cy, outerR + PIE_LABEL_OFFSET, mid); const fill = pie.label.fill || pie.colors[i]; if (pie.labelLine) { const startPoint = polarToCartesian(cx, cy, outerR, mid); svg += `<path class="recharts-curve recharts-pie-label-line" stroke="${pie.colors[i]}" fill="none" d="M${fmtF(startPoint.x)},${fmtF(startPoint.y)}L${fmtF(endPoint.x)},${fmtF(endPoint.y)}"/>`; } svg += `<text class="recharts-text recharts-pie-label-text" x="${fmtF(endPoint.x)}" y="${fmtF(endPoint.y)}" fill="${fill}" text-anchor="${pieTextAnchor(endPoint.x, cx)}" alignment-baseline="middle">${fmtF(pie.values[i])}</text>`; } svg += `</g>`; } // A LabelList on a Pie is a polar label: it sits at the middle radius // of its sector. if (alpha >= 1 && pie.labelList) { const ll = pie.labelList; svg += `<g class="recharts-layer recharts-label-list">`; for (let i = 0; i < sectors.length; i++) { const r = (innerR + outerR) / 2; const pt = polarToCartesian(cx, cy, r, sectors[i].mid); svg += `<text class="recharts-text recharts-label ${ll.class || ""}" x="${fmtF(pt.x)}" y="${fmtF(pt.y)}" text-anchor="middle" font-size="${fmtF(ll.fontSize || 12)}"><tspan dy="0.355em">${ll.labels[i]}</tspan></text>`; } svg += `</g>`; } if (pie.center) { svg += centerLabelSVG(cx, cy, pie.center); } svg += "</g>"; state.points.sectors.push(sectors); }); svg += "</svg>"; swapSVG(panel, svg); state.geom = { W, H };} /* renderRadialLabel of Recharts' Label: an insideStart label runs along an * arc through the middle of its own sector. */function radialLabelSVG(id, text, ll, fill, cx, cy, sc) { const radius = (sc.inner + sc.outer) / 2; // getDeltaAngle, then the insideStart branch with the default offset of 5. const delta = (sc.end - sc.start < 0 ? -1 : 1) * Math.min(Math.abs(sc.end - sc.start), 360); const sign = delta >= 0 ? 1 : -1; const labelAngle = sc.start + sign * (ll.offset || 5); // clockWise is false on a radial bar's view box, and a positive sweep // flips it. const direction = delta <= 0 ? false : true; const startPoint = polarToCartesian(cx, cy, radius, labelAngle); const endPoint = polarToCartesian(cx, cy, radius, labelAngle + (direction ? 1 : -1) * 359); const path = `M${fmtF(startPoint.x)},${fmtF(startPoint.y)} A${fmtF(radius)},${fmtF(radius)},0,1,${direction ? 0 : 1}, ${fmtF(endPoint.x)},${fmtF(endPoint.y)}`; const fontSize = ll.fontSize ? ` font-size="${fmtF(ll.fontSize)}"` : ""; return ( `<text fill="${fill}"${fontSize} dominant-baseline="central" class="recharts-radial-bar-label${ll.class ? " " + ll.class : ""}">` + `<defs><path id="${id}" d="${path}"/></defs><textPath xlink:href="#${id}">${text}</textPath></text>` );} /* renderRadial draws a RadialBarChart: the radius axis is a band scale over * the rings, the angle axis a linear scale over the values. */function renderRadial(panel, m, state, alpha = 1) { const W = panel.clientWidth; const H = panel.clientHeight; if (!W || !H) return; const legendHeight = legendSize(panel, m); const offsetW = Math.max(W - m.marginLeft - m.marginRight, 0); const offsetH = Math.max(H - m.marginTop - m.marginBottom - legendHeight, 0); const cx = m.marginLeft + offsetW / 2; const cy = m.marginTop + offsetH / 2; // getMaxRadius, then the RadialBarChart defaults: no inner radius and an // outer radius of 80%. const maxRadius = Math.min(offsetW, offsetH) / 2; const rad = m.radial || {}; const innerRadius = rad.innerRadius || 0; const outerRadius = rad.outerRadius || maxRadius * 0.8; const startAngle = rad.startAngle || 0; const endAngle = rad.endAngle; const n = m.series.length ? m.series[0].values.length : 0; if (!n) return; // The angle axis is a number axis with the [0, 'auto'] default domain, so // it spans zero to the largest raw value. let domainMin = 0; let domainMax = 0; for (const s of m.series) { for (const v of s.values) { if (v > domainMax) domainMax = v; if (v < domainMin) domainMin = v; } } const span = domainMax - domainMin || 1; const angleOf = (v) => startAngle + ((v - domainMin) / span) * (endAngle - startAngle); // The radius axis is a band scale, one band per row, and getBarPosition // places the bars inside it. Bars of one stack share their position. const bandSize = (outerRadius - innerRadius) / n; const groupKeys = []; const groupOf = m.series.map((s, si) => { const key = s.stackId ? "stack:" + s.stackId : "bar:" + si; let gi = groupKeys.indexOf(key); if (gi < 0) { gi = groupKeys.length; groupKeys.push(key); } return gi; }); const pos = barPosition(bandSize, groupKeys.length); // A stack chains its ranges and truncateByDomain clamps each one into // the angle domain, so a stacked bar never runs past the chart's end // angle. An unstacked bar starts at the base value of the number axis, // zero. const totals = {}; const ranges = m.series.map((s) => { const out = []; for (let i = 0; i < n; i++) { const v = s.values[i]; if (s.stackId) { const key = s.stackId + "|" + i; const base = totals[key] || 0; out.push(truncateByDomain([base, base + v], domainMin, domainMax)); totals[key] = base + v; } else { out.push([0, v]); } } return out; }); let svg = `<svg class="recharts-surface" width="${fmtF(W)}" height="${fmtF(H)}" viewBox="0 0 ${fmtF(W)} ${fmtF(H)}">`; const polar = m.polar || {}; if (polar.hasGrid) { // getTicksOfAxis for the grid: a band scale puts its ticks in the // middle of the band, the angle axis has no tick count of its own. const radii = polar.polarRadius && polar.polarRadius.length ? polar.polarRadius : Array.from({ length: n }, (_, i) => innerRadius + i * bandSize + bandSize / 2); const angles = d3Ticks(domainMin, domainMax, 10).map(angleOf); svg += polarGridSVG(polar, cx, cy, innerRadius, outerRadius, radii, angles); } const sectors = []; m.series.forEach((s, si) => { const barOffset = pos.offset + (pos.size + pos.gap) * groupOf[si]; let background = ""; let shapes = ""; let labels = ""; const bar = []; for (let i = 0; i < n; i++) { const inner = innerRadius + i * bandSize + barOffset; const outer = inner + pos.size; const from = angleOf(ranges[si][i][0]); const to = angleOf(ranges[si][i][1]); // The mount animation interpolates the end angle out of the start // angle, so a sector opens up. const end = alpha < 1 ? from + (to - from) * alpha : to; if (s.background) { background += `<g class="recharts-layer recharts-shape">` + `<path class="recharts-sector recharts-radial-bar-background-sector" fill="#eee" d="${sector(cx, cy, inner, outer, startAngle, endAngle, s.cornerRadius)}"/>` + `</g>`; } // The entry's own fill wins over the bar's, like Recharts spreading // the data row over the sector props. const fill = (s.cells && s.cells[i]) || s.fill || s.color; shapes += `<g class="recharts-layer recharts-shape">` + `<path class="recharts-sector recharts-radial-bar-sector" fill="${fill}" d="${sector(cx, cy, inner, outer, from, end, s.cornerRadius)}"/>` + `</g>`; bar.push({ inner, outer, start: from, end }); // The label list only shows once the animation is done, Recharts' // showLabels={!isAnimating}. if (s.labelList && alpha >= 1) { labels += radialLabelSVG(`${state.uid}-${si}-${i}`, s.labelList.labels[i], s.labelList, fill, cx, cy, { inner, outer, start: from, end }); } } sectors.push(bar); svg += `<g class="recharts-layer recharts-area${s.class ? " " + s.class : ""}">` + (s.background ? `<g class="recharts-layer recharts-radial-bar-background">${background}</g>` : "") + `<g class="recharts-layer recharts-radial-bar-sectors">${shapes}${labels}</g>` + `</g>`; }); if (rad.center) { svg += centerLabelSVG(cx, cy, rad.center); } svg += "</svg>"; swapSVG(panel, svg); // The tooltip picks its row by the radius, so it needs the band centers. const tickCoords = Array.from({ length: n }, (_, i) => innerRadius + i * bandSize + bandSize / 2); state.geom = { W, H, cx, cy, innerRadius, outerRadius, startAngle, endAngle, tickCoords, sectors };} /* ---------------------------------------------------------------- *//* Tooltip + cursor *//* ---------------------------------------------------------------- */ function tooltipWrapper(container) { let wrapper = container.querySelector(":scope > .recharts-tooltip-wrapper"); if (!wrapper) { wrapper = document.createElement("div"); wrapper.className = "recharts-tooltip-wrapper"; wrapper.style.cssText = "position:absolute;top:0;left:0;pointer-events:none;visibility:hidden;z-index:30;transition:transform 400ms ease"; container.style.position = "relative"; container.appendChild(wrapper); } return wrapper;} function indicatorHTML(indicator, color, nestLabel) { let cls = "shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"; if (indicator === "line") cls += " w-1"; else if (indicator === "dashed") cls += ` w-0 border-[1.5px] border-dashed bg-transparent${nestLabel ? " my-0.5" : ""}`; else cls += " h-2.5 w-2.5"; return `<div class="${cls}" style="--color-bg:${color};--color-border:${color}"></div>`;} function tooltipHTML(m, i, pieIndex = 0) { const t = m.tooltip || {}; // labelKey resolves the label through the config; a pie falls back to // the config label of its own data key, like getPayloadConfigFromPayload // reading item.dataKey. const pie = m.kind === "pie" ? m.pies[pieIndex] : null; const label = pie ? pie.seriesLabel || t.label : t.label || (m.tooltipLabels && m.tooltipLabels[i]) || m.labels[i]; // Like ChartTooltipContent: a single non-dot payload nests the label // inside the row, so the line indicator spans the full row height. A pie // always carries a single payload item. const nestLabel = (pie ? true : m.series.length === 1) && t.indicator && t.indicator !== "dot"; const labelCls = `font-medium${t.labelClass ? " " + t.labelClass : ""}`; let html = `<div class="${TOOLTIP_CLASS}${t.width ? " " + t.width : ""}">`; if (!t.hideLabel && !nestLabel) { html += `<div class="${labelCls}">${label}</div>`; } html += `<div class="grid gap-1.5">`; if (pie) { const color = t.color || pie.colors[i]; const rowCls = "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground" + (t.indicator !== "line" && t.indicator !== "dashed" ? " items-center" : ""); const nested = nestLabel && !t.hideLabel ? `<div class="${labelCls}">${label}</div>` : ""; html += `<div class="${rowCls}">` + (t.hideIndicator ? "" : indicatorHTML(t.indicator, color, nestLabel)) + `<div class="flex flex-1 justify-between leading-none ${nestLabel ? "items-end" : "items-center"}">` + `<div class="grid gap-1.5">${nested}<span class="text-muted-foreground">${(pie.tooltipNames && pie.tooltipNames[i]) || pie.labels[i]}</span></div>` + `<span class="font-mono font-medium text-foreground tabular-nums">${pie.values[i].toLocaleString("en-US")}</span>` + `</div></div></div></div>`; return html; } m.series.forEach((s, si) => { const rowCls = "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground" + (t.indicator !== "line" && t.indicator !== "dashed" ? " items-center" : ""); // The formatter markup replaces the row's default indicator, name and // value, like ChartTooltipContent calling the formatter render prop. if (t.rows && t.rows[si]) { html += `<div class="${rowCls}">${t.rows[si][i]}</div>`; return; } const nested = nestLabel && !t.hideLabel ? `<div class="${labelCls}">${label}</div>` : ""; // ChartTooltipContent's indicatorColor: the color prop wins, then the // row's own fill, then the series color, so per row colored bars keep // their swatch. const indicatorColor = t.color || (s.cells && s.cells[i]) || s.color; // getPayloadConfigFromPayload: a name key that the rows answer names // every row on its own, otherwise the series carries the name. const name = (s.tooltipNames && s.tooltipNames[i]) || s.label; html += `<div class="${rowCls}">` + (s.icon || (t.hideIndicator ? "" : indicatorHTML(t.indicator, indicatorColor, nestLabel))) + `<div class="flex flex-1 justify-between leading-none ${nestLabel ? "items-end" : "items-center"}">` + `<div class="grid gap-1.5">${nested}<span class="text-muted-foreground">${name}</span></div>` + `<span class="font-mono font-medium text-foreground tabular-nums">${s.values[i].toLocaleString("en-US")}</span>` + `</div></div>`; }); html += "</div></div>"; return html;} function showCursor(panel, m, state, i) { const svg = panel.querySelector("svg.recharts-surface"); if (!svg || m.cursor === false) return; let cursor = svg.querySelector(".recharts-tooltip-cursor"); const g = state.geom; // Recharts draws the cursor between the grid and the series, so bars // and areas render on top of the hover band. const seriesLayer = svg.querySelector(".recharts-bar, .recharts-area, .recharts-line, .recharts-radar"); if (m.kind === "radial") { // getRadialCursorPoints: the cursor is a sector of zero width, an arc // at the radius the pointer sits on. const d = sector(g.cx, g.cy, state.cursorRadius, state.cursorRadius, g.startAngle, g.endAngle, 0); if (!cursor) { seriesLayer.insertAdjacentHTML("beforebegin", `<g class="recharts-layer"><path class="recharts-sector recharts-tooltip-cursor" stroke="#ccc" fill="none" d="${d}"/></g>`); } else { cursor.setAttribute("d", d); } return; } if (m.kind === "radar") { const end = polarToCartesian(g.cx, g.cy, g.outerRadius, g.angles[i]); const d = `M${fmtF(g.cx)},${fmtF(g.cy)}L${fmtF(end.x)},${fmtF(end.y)}`; if (!cursor) { seriesLayer.insertAdjacentHTML("beforebegin", `<g class="recharts-layer"><path class="recharts-curve recharts-tooltip-cursor" stroke="#ccc" fill="none" d="${d}"/></g>`); } else { cursor.setAttribute("d", d); } return; } if (m.kind === "bar") { const d = g.vertical ? `M ${fmtF(g.plotX)},${fmtF(g.plotY + i * g.band)} h ${fmtF(g.plotW)} v ${fmtF(g.band)} h ${fmtF(-g.plotW)} Z` : `M ${fmtF(g.plotX + i * g.band)},${fmtF(g.plotY)} h ${fmtF(g.band)} v ${fmtF(g.plotH)} h ${fmtF(-g.band)} Z`; if (!cursor) { seriesLayer.insertAdjacentHTML( "beforebegin", `<g class="recharts-layer"><path class="recharts-rectangle recharts-tooltip-cursor" fill="#ccc" d="${d}"/></g>` ); } else { cursor.setAttribute("d", d); } } else { const x = g.xs[i]; const d = `M${fmtF(x)},${fmtF(g.plotY)}L${fmtF(x)},${fmtF(g.plotBottom)}`; if (!cursor) { seriesLayer.insertAdjacentHTML( "beforebegin", `<g class="recharts-layer"><path class="recharts-curve recharts-tooltip-cursor" stroke="#ccc" fill="none" stroke-width="1" d="${d}"/></g>` ); } else { cursor.setAttribute("d", d); } }} function hideCursor(panel) { const cursor = panel.querySelector(".recharts-tooltip-cursor"); if (cursor) cursor.parentElement.remove(); hideActiveDots(panel);} // showActiveDots is Recharts' Area/Line activeDot: a dot per series on the// active data point while the tooltip is up.function showActiveDots(panel, m, state, i) { const svg = panel.querySelector("svg.recharts-surface"); if (!svg || (m.kind !== "area" && m.kind !== "line" && m.kind !== "radar") || !state.tops) return; let layer = svg.querySelector(".recharts-active-dots"); if (!layer) { svg.insertAdjacentHTML("beforeend", `<g class="recharts-layer recharts-active-dots"></g>`); layer = svg.querySelector(".recharts-active-dots"); } let html = ""; for (let s = 0; s < m.series.length; s++) { // renderActivePoint's defaults: r 4, white stroke of 2, filled with the // item's main color. getLegendItemColor prefers the stroke over the fill. const series = m.series[s]; const r = series.activeDotR || 4; const mainColor = series.stroke && series.stroke !== "none" ? series.stroke : series.fill || series.color || "none"; // A radar point carries its own x, the cartesian charts share the // category positions. const point = m.kind === "radar" ? state.points.radar[s][i] : { x: state.geom.xs[i], y: state.tops[s][i] }; html += `<circle class="recharts-dot" r="${fmtF(r)}" stroke="#fff" stroke-width="2" fill="${mainColor}" cx="${fmtF(point.x)}" cy="${fmtF(point.y)}"/>`; } layer.innerHTML = html;} function hideActiveDots(panel) { const layer = panel.querySelector(".recharts-active-dots"); if (layer) layer.remove();} /* ---------------------------------------------------------------- *//* Wiring *//* ---------------------------------------------------------------- */ const OFFSET = 10; /* getTooltipTranslateXY: the tooltip sits after the coordinate, and flips * before it once it would leave the view box, clamped to the view box. */function tooltipTranslate(coordinate, tooltipDimension, viewBoxKey, viewBoxDimension) { const negative = coordinate - tooltipDimension - (OFFSET > 0 ? OFFSET : 0); const positive = coordinate + OFFSET; const tooltipBoundary = positive + tooltipDimension; const viewBoxBoundary = viewBoxKey + viewBoxDimension; if (tooltipBoundary > viewBoxBoundary) { return Math.max(negative, viewBoxKey); } return Math.max(positive, viewBoxKey);} function initPanel(script) { if (script.dataset.tuiChartInit) return; script.dataset.tuiChartInit = "true"; const panel = script.parentElement; const container = panel.closest("[data-tui-chart]"); const m = JSON.parse(script.textContent); const state = { uid: "tui-chart-" + uid++ }; const render = (alpha = 1) => { if (m.kind === "pie") renderPie(panel, m, state, alpha); else if (m.kind === "radar") renderRadar(panel, m, state, alpha); else if (m.kind === "radial") renderRadial(panel, m, state, alpha); else renderCartesian(panel, m, state, alpha); // The points this panel drew are what the next visible panel morphs // from, Recharts' previousPointsRef. if (state.points) container._tuiActivePoints = state.points; // Recharts renders the cursor and the active dots from the tooltip // state on every pass, so a hover keeps its overlays through the // entrance animation even though each frame rebuilds the surface. if (state.activeIndex != null) { showCursor(panel, m, state, state.activeIndex); showActiveDots(panel, m, state, state.activeIndex); } // The default tooltip waits for the first real layout of a panel that // mounted hidden. if (state.onFirstRender && state.geom) { const f = state.onFirstRender; state.onFirstRender = null; f(); } }; // On mount the entrance animation plays. When a hidden panel becomes // visible (range/series/month switches) Recharts morphs from the old // to the new values instead, so we interpolate from the previously // visible panel's model when the shapes line up. Plain resizes // re-render without animating. const enter = () => { const prev = container._tuiActive; const prevPoints = container._tuiActivePoints; container._tuiActive = m; const sameShape = m.kind === "pie" ? prev && prev.pies && m.pies && prev.pies.length === m.pies.length : prev && prev.series && m.series && prev.series.length === m.series.length; if (prev && prev !== m && prevPoints && prev.kind === m.kind && sameShape) { morphChart(panel, m, state, render, prevPoints); } else { animateChart(panel, m, state, render); } }; if (panel.clientWidth) { state.mounted = true; enter(); } const ro = new ResizeObserver(() => { const w = panel.clientWidth; if (!w) { state.mounted = false; return; } if (!state.mounted) { state.mounted = true; enter(); } else if (!state.geom || w !== state.geom.W) { // Real container resizes re-render statically. state.geom.W is // updated on every animation frame, so a late observer callback // never stomps into a running animation. render(1); } }); ro.observe(panel); // Without a declared Tooltip child Recharts renders no tooltip, no // active dots and no cursor, so none of the hover wiring applies. if (!m.hasTooltip) return; const wrapper = tooltipWrapper(container); // TooltipBoundingBox: Escape dismisses the tooltip box at its current // coordinate; the cursor and the active dots stay up, and the box comes // back as soon as the coordinate changes. state.dismissed = false; state.dismissedAt = { x: 0, y: 0 }; document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; state.dismissed = true; state.dismissedAt = state.tooltipCoord || { x: 0, y: 0 }; wrapper.style.visibility = "hidden"; }); // Like Recharts' inRange: the tooltip only activates while the pointer // is inside the plot rectangle, not over the axis labels below. const activeIndexAt = (chartX, chartY) => { const g = state.geom; if (!g) return -1; if (m.kind === "radar") { // inRangeOfSector: only inside the outer radius, then the angle // decides which tick is active. const p = angleOfPoint(chartX, chartY, g.cx, g.cy); if (p.radius > g.outerRadius) return -1; // The angles run from 90 downwards, the point angle from 0 to 360. const angle = p.angle > 90 ? p.angle - 360 : p.angle; return activeAngleIndex(angle, g.angles); } if (m.kind === "radial") { // inRangeOfSector, then calculateTooltipPos: in a radial layout the // radius picks the row. const p = angleOfPoint(chartX, chartY, g.cx, g.cy); if (p.radius < g.innerRadius || p.radius > g.outerRadius || p.radius === 0) return -1; if (!inAngleRangeOfSector(p.angle, g.startAngle, g.endAngle)) return -1; state.cursorRadius = p.radius; return activeTickIndex(p.radius, g.tickCoords); } if (chartX < g.plotX || chartX > g.plotX + g.plotW) return -1; if (chartY < g.plotY || chartY > g.plotBottom) return -1; if (m.kind === "bar") { // The category runs down the y axis in a vertical layout. const along = g.vertical ? chartY - g.plotY : chartX - g.plotX; return Math.max(0, Math.min(g.n - 1, Math.floor(along / g.band))); } const step = g.plotW / (g.n - 1); return Math.max(0, Math.min(g.n - 1, Math.round((chartX - g.plotX) / step))); }; // parseEventsOfWrapper: the tooltip listens to mouse events, and an axis // tooltip additionally to touchmove, whose handleTouchMove feeds the // changed touch into the mouse handler. A touch tap shows the tooltip // through the browser's compatibility mousemove and it stays until a tap // elsewhere fires mouseleave; a finger dragged across the plot keeps // scrubbing through touchmove even while the page scrolls. An item // tooltip (pie) attaches no wrapper touch events. const handleMouseMove = (e) => { if (m.kind === "pie") { const sector = e.target.closest(".recharts-sector"); const idx = sector ? parseInt(sector.getAttribute("data-tui-chart-sector") || "-1", 10) : -1; if (idx < 0) { wrapper.style.visibility = "hidden"; return; } positionTooltip(e, null, null, idx, parseInt(sector.getAttribute("data-tui-chart-pie") || "0", 10)); return; } const rect = panel.getBoundingClientRect(); const chartX = e.clientX - rect.left; const chartY = e.clientY - rect.top; const i = activeIndexAt(chartX, chartY); state.hoverActive = i >= 0; if (i < 0) { resolveTooltip(); return; } state.activeIndex = i; showCursor(panel, m, state, i); showActiveDots(panel, m, state, i); // getActiveCoordinate: the category axis snaps to its tick and the // other one follows the pointer, so a vertical layout snaps y. const g = state.geom; if (m.kind === "radar" || m.kind === "radial") { positionTooltip(e, null, null, i); return; } const snap = g ? g.cats[i] : null; positionTooltip(e, g && g.vertical ? null : snap, g && g.vertical ? snap : null, i); }; panel.addEventListener("mousemove", handleMouseMove); if (m.kind !== "pie") { panel.addEventListener("touchmove", (e) => { if (e.changedTouches != null && e.changedTouches.length > 0) { handleMouseMove(e.changedTouches[0]); } }); } function positionTooltip(e, snapX, snapY, i, pieIndex = 0) { const wasHidden = wrapper.style.visibility !== "visible"; wrapper.innerHTML = tooltipHTML(m, i, pieIndex); wrapper.style.visibility = "visible"; const crect = container.getBoundingClientRect(); const tw = wrapper.offsetWidth; const th = wrapper.offsetHeight; const prect = panel.getBoundingClientRect(); const px = snapX != null ? snapX + (prect.left - crect.left) : e.clientX - crect.left; const py = snapY != null ? snapY + (prect.top - crect.top) : e.clientY - crect.top; // A dismissed tooltip stays hidden until its coordinate changes. if (state.dismissed) { if (px === state.dismissedAt.x && py === state.dismissedAt.y) { wrapper.style.visibility = "hidden"; } else { state.dismissed = false; } } state.tooltipCoord = { x: px, y: py }; const tx = tooltipTranslate(px, tw, 0, crect.width); const ty = tooltipTranslate(py, th, 0, crect.height); if (wasHidden) { // Appear in place like Recharts, the transition only trails while // the tooltip is already visible. wrapper.style.transition = "none"; wrapper.style.transform = `translate(${tx}px, ${ty}px)`; void wrapper.offsetHeight; wrapper.style.transition = "transform 400ms ease"; return; } wrapper.style.transform = `translate(${tx}px, ${ty}px)`; } panel.addEventListener("mouseleave", () => { state.hoverActive = false; resolveTooltip(); }); // displayDefaultTooltip: a Tooltip with a defaultIndex shows once on // mount, at the category's tick coordinate and halfway between the plot // top and the chart height, Recharts' (offset.top + height) / 2. const di = m.tooltip && m.tooltip.defaultIndex; if (typeof di === "number") { const show = () => { const g = state.geom; if (!g || !g.cats || di < 0 || di > g.cats.length - 1) return; const dep = (g.plotY + g.H) / 2; state.activeIndex = di; showCursor(panel, m, state, di); showActiveDots(panel, m, state, di); positionTooltip(null, g.vertical ? dep : g.cats[di], g.vertical ? g.cats[di] : dep, di); }; if (state.geom) show(); else state.onFirstRender = show; } // The keyboard interaction of Recharts' keyboardEventsMiddleware: the // first focus activates index 0, the arrow keys walk the categories and // stop at the ends, Enter toggles the interaction at the current index // and blur deactivates it but keeps the index. state.keyboard = { active: false, index: null }; // spoofKeyboard shows the keyboard tooltip like Recharts spoofs a mouse // move: at the tick coordinate and offset.top plus half the height, and // only in the horizontal layout. const spoofKeyboard = () => { const g = state.geom; if (!g || !g.cats || g.vertical) return; const i = Math.min(Math.max(state.keyboard.index, 0), g.cats.length - 1); const rect = panel.getBoundingClientRect(); const e = { clientX: rect.left + g.cats[i], clientY: rect.top + g.plotY + g.H / 2 }; state.activeIndex = i; showCursor(panel, m, state, i); showActiveDots(panel, m, state, i); positionTooltip(e, g.cats[i], null, i); }; // combineTooltipInteractionState: an active hover always wins, then the // active keyboard interaction, otherwise the tooltip hides. This is why // a touch tap stays where the finger is even though the tap also // focuses the surface. function resolveTooltip() { if (state.hoverActive) return; if (state.keyboard.active) { spoofKeyboard(); return; } state.activeIndex = null; wrapper.style.visibility = "hidden"; hideCursor(panel); } if (m.accessibilityLayer !== false) { // focusAction: only the first focus activates the keyboard // interaction, at index 0; a refocus after blur keeps the tooltip // hidden until the arrow keys move it again. panel.addEventListener("focusin", () => { if (state.keyboard.active) return; if (state.keyboard.index == null) { state.keyboard = { active: true, index: 0 }; resolveTooltip(); } }); // blurAction deactivates the interaction but keeps the index. panel.addEventListener("focusout", () => { if (state.keyboard.active) { state.keyboard.active = false; resolveTooltip(); } }); panel.addEventListener("keydown", (e) => { const g = state.geom; if (!g || !g.cats || g.vertical) return; if (e.key === "Enter") { if (state.keyboard.index == null) return; state.keyboard.active = !state.keyboard.active; resolveTooltip(); return; } if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return; const movement = e.key === "ArrowRight" ? 1 : -1; const next = state.keyboard.index == null ? (movement > 0 ? 0 : g.cats.length - 1) : state.keyboard.index + movement; if (next < 0 || next > g.cats.length - 1) return; state.keyboard = { active: true, index: next }; resolveTooltip(); }); }} function init() { document.querySelectorAll("script[data-tui-chart-model]").forEach(initPanel);} /* Interactive demo wiring: selects and header buttons toggle the SSR * rendered variants of a chart. */document.addEventListener("select-change", (e) => { const trigger = e.target instanceof Element && e.target.closest("[data-tui-chart-range-select], [data-tui-chart-month-select]"); if (!trigger) return; const value = e.detail && e.detail.value; if (!value) return; const chart = trigger.closest("[data-slot=card]"); if (!chart) return; const attr = trigger.hasAttribute("data-tui-chart-range-select") ? "data-tui-chart-range" : "data-tui-chart-month"; chart.querySelectorAll(`[${attr}]`).forEach((el) => { el.hidden = el.getAttribute(attr) !== value; });}); document.addEventListener("click", (e) => { if (!(e.target instanceof Element)) return; const btn = e.target.closest("[data-tui-chart-series]"); if (!btn) return; const chart = btn.closest("[data-slot=card]"); if (!chart) return; const series = btn.getAttribute("data-tui-chart-series"); chart.querySelectorAll("[data-tui-chart-series]").forEach((b) => { b.setAttribute("data-active", b === btn ? "true" : "false"); }); chart.querySelectorAll("[data-tui-chart-series-panel]").forEach((el) => { el.hidden = el.getAttribute("data-tui-chart-series-panel") !== series; });}); // Setup on load and on mutations, the shadcn-templ convention: init is// idempotent (every panel carries its own init flag), so any inserted// node just re-runs the full scan, no matter what put it into the DOM.if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init);} else { init();}new MutationObserver((mutations) => { for (const m of mutations) { if (m.addedNodes.length) { init(); return; } }}).observe(document.documentElement, { childList: true, subtree: true });Add the following colors to your CSS file
@layer base { :root { --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); } .dark { --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); }}Component scripts are loaded through the shared script bundle, see JavaScript.
Update the import paths to match your project setup.
Your First Chart
Let’s build your first chart. We’ll build a bar chart, add a grid, axis, tooltip and legend.
Start by defining your data
The following data represents the number of desktop and mobile users for each month.
Note: Your data can be in any shape. You are not limited to the shape of the data below. Use the DataKey prop to map your data to the chart.
var chartData = []chart.Datum{ {"month": "January", "desktop": 186, "mobile": 80}, {"month": "February", "desktop": 305, "mobile": 200}, {"month": "March", "desktop": 237, "mobile": 120}, {"month": "April", "desktop": 73, "mobile": 190}, {"month": "May", "desktop": 209, "mobile": 130}, {"month": "June", "desktop": 214, "mobile": 140},}Define your chart config
The chart config holds configuration for the chart. This is where you place human-readable strings, such as labels, icons and color tokens for theming.
var chartConfig = chart.Config{ {Key: "desktop", Label: "Desktop", Color: "#2563eb"}, {Key: "mobile", Label: "Mobile", Color: "#60a5fa"},}Build your chart
You can now build your chart using the chart components.
Important: Remember to set a min-h-[VALUE] on the Container component. This is required for the chart to be responsive.
@chart.Container(chart.ContainerProps{ID: "chart-example", Config: chartConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartData}) { @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) }}package examples import "github.com/axadrn/shadcn-templ/v2/components/chart"package examples import "github.com/axadrn/shadcn-templ/v2/components/chart" // The "Your First Chart" steps from the chart docs, the pendants of// chart-example.tsx and the grid, axis, tooltip and legend variants. var chartExampleData = []chart.Datum{ {"month": "January", "desktop": 186, "mobile": 80}, {"month": "February", "desktop": 305, "mobile": 200}, {"month": "March", "desktop": 237, "mobile": 120}, {"month": "April", "desktop": 73, "mobile": 190}, {"month": "May", "desktop": 209, "mobile": 130}, {"month": "June", "desktop": 214, "mobile": 140},} var chartExampleConfig = chart.Config{ {Key: "desktop", Label: "Desktop", Color: "#2563eb"}, {Key: "mobile", Label: "Mobile", Color: "#60a5fa"},} func chartExampleMonthShort(v any) string { return v.(string)[:3]} templ ChartExample() { @chart.Container(chart.ContainerProps{ID: "chart-example", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }} templ ChartExampleGrid() { @chart.Container(chart.ContainerProps{ID: "chart-example-grid", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }} templ ChartExampleAxis() { @chart.Container(chart.ContainerProps{ID: "chart-example-axis", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.XAxis(chart.XAxisProps{ DataKey: "month", TickMargin: 10, TickFormatter: chartExampleMonthShort, }) @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }} templ ChartExampleTooltip() { @chart.Container(chart.ContainerProps{ID: "chart-example-tooltip", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.XAxis(chart.XAxisProps{ DataKey: "month", TickMargin: 10, TickFormatter: chartExampleMonthShort, }) @chart.Tooltip() @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }} templ ChartExampleLegend() { @chart.Container(chart.ContainerProps{ID: "chart-example-legend", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.XAxis(chart.XAxisProps{ DataKey: "month", TickMargin: 10, TickFormatter: chartExampleMonthShort, }) @chart.Tooltip() @chart.Legend() @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }}Add a Grid
Let’s add a grid to the chart.
Add the CartesianGrid component to your chart.
@chart.Container(chart.ContainerProps{ID: "chart-example", Config: chartConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) }}package examples import "github.com/axadrn/shadcn-templ/v2/components/chart"package examples import "github.com/axadrn/shadcn-templ/v2/components/chart" // The "Your First Chart" steps from the chart docs, the pendants of// chart-example.tsx and the grid, axis, tooltip and legend variants. var chartExampleData = []chart.Datum{ {"month": "January", "desktop": 186, "mobile": 80}, {"month": "February", "desktop": 305, "mobile": 200}, {"month": "March", "desktop": 237, "mobile": 120}, {"month": "April", "desktop": 73, "mobile": 190}, {"month": "May", "desktop": 209, "mobile": 130}, {"month": "June", "desktop": 214, "mobile": 140},} var chartExampleConfig = chart.Config{ {Key: "desktop", Label: "Desktop", Color: "#2563eb"}, {Key: "mobile", Label: "Mobile", Color: "#60a5fa"},} func chartExampleMonthShort(v any) string { return v.(string)[:3]} templ ChartExample() { @chart.Container(chart.ContainerProps{ID: "chart-example", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }} templ ChartExampleGrid() { @chart.Container(chart.ContainerProps{ID: "chart-example-grid", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }} templ ChartExampleAxis() { @chart.Container(chart.ContainerProps{ID: "chart-example-axis", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.XAxis(chart.XAxisProps{ DataKey: "month", TickMargin: 10, TickFormatter: chartExampleMonthShort, }) @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }} templ ChartExampleTooltip() { @chart.Container(chart.ContainerProps{ID: "chart-example-tooltip", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.XAxis(chart.XAxisProps{ DataKey: "month", TickMargin: 10, TickFormatter: chartExampleMonthShort, }) @chart.Tooltip() @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }} templ ChartExampleLegend() { @chart.Container(chart.ContainerProps{ID: "chart-example-legend", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.XAxis(chart.XAxisProps{ DataKey: "month", TickMargin: 10, TickFormatter: chartExampleMonthShort, }) @chart.Tooltip() @chart.Legend() @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }}Add an Axis
To add an x-axis to the chart, we’ll use the XAxis component.
Define a tick formatter
The TickFormatter is a plain Go function. This one shortens the month names to three letters.
func monthShort(v any) string { return v.(string)[:3]}Add the XAxis component to your chart.
@chart.Container(chart.ContainerProps{ID: "chart-example", Config: chartConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.XAxis(chart.XAxisProps{ DataKey: "month", TickMargin: 10, TickFormatter: monthShort, }) @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) }}package examples import "github.com/axadrn/shadcn-templ/v2/components/chart"package examples import "github.com/axadrn/shadcn-templ/v2/components/chart" // The "Your First Chart" steps from the chart docs, the pendants of// chart-example.tsx and the grid, axis, tooltip and legend variants. var chartExampleData = []chart.Datum{ {"month": "January", "desktop": 186, "mobile": 80}, {"month": "February", "desktop": 305, "mobile": 200}, {"month": "March", "desktop": 237, "mobile": 120}, {"month": "April", "desktop": 73, "mobile": 190}, {"month": "May", "desktop": 209, "mobile": 130}, {"month": "June", "desktop": 214, "mobile": 140},} var chartExampleConfig = chart.Config{ {Key: "desktop", Label: "Desktop", Color: "#2563eb"}, {Key: "mobile", Label: "Mobile", Color: "#60a5fa"},} func chartExampleMonthShort(v any) string { return v.(string)[:3]} templ ChartExample() { @chart.Container(chart.ContainerProps{ID: "chart-example", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }} templ ChartExampleGrid() { @chart.Container(chart.ContainerProps{ID: "chart-example-grid", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }} templ ChartExampleAxis() { @chart.Container(chart.ContainerProps{ID: "chart-example-axis", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.XAxis(chart.XAxisProps{ DataKey: "month", TickMargin: 10, TickFormatter: chartExampleMonthShort, }) @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }} templ ChartExampleTooltip() { @chart.Container(chart.ContainerProps{ID: "chart-example-tooltip", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.XAxis(chart.XAxisProps{ DataKey: "month", TickMargin: 10, TickFormatter: chartExampleMonthShort, }) @chart.Tooltip() @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }} templ ChartExampleLegend() { @chart.Container(chart.ContainerProps{ID: "chart-example-legend", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.XAxis(chart.XAxisProps{ DataKey: "month", TickMargin: 10, TickFormatter: chartExampleMonthShort, }) @chart.Tooltip() @chart.Legend() @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }}Add Tooltip
So far we’ve only used components from the chart element set. They look great out of the box thanks to some customization in the chart component.
To add a tooltip, we’ll use the custom Tooltip component from chart.
Add the Tooltip component to your chart.
@chart.Container(chart.ContainerProps{ID: "chart-example", Config: chartConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.XAxis(chart.XAxisProps{ DataKey: "month", TickMargin: 10, TickFormatter: monthShort, }) @chart.Tooltip() @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) }}package examples import "github.com/axadrn/shadcn-templ/v2/components/chart"package examples import "github.com/axadrn/shadcn-templ/v2/components/chart" // The "Your First Chart" steps from the chart docs, the pendants of// chart-example.tsx and the grid, axis, tooltip and legend variants. var chartExampleData = []chart.Datum{ {"month": "January", "desktop": 186, "mobile": 80}, {"month": "February", "desktop": 305, "mobile": 200}, {"month": "March", "desktop": 237, "mobile": 120}, {"month": "April", "desktop": 73, "mobile": 190}, {"month": "May", "desktop": 209, "mobile": 130}, {"month": "June", "desktop": 214, "mobile": 140},} var chartExampleConfig = chart.Config{ {Key: "desktop", Label: "Desktop", Color: "#2563eb"}, {Key: "mobile", Label: "Mobile", Color: "#60a5fa"},} func chartExampleMonthShort(v any) string { return v.(string)[:3]} templ ChartExample() { @chart.Container(chart.ContainerProps{ID: "chart-example", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }} templ ChartExampleGrid() { @chart.Container(chart.ContainerProps{ID: "chart-example-grid", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }} templ ChartExampleAxis() { @chart.Container(chart.ContainerProps{ID: "chart-example-axis", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.XAxis(chart.XAxisProps{ DataKey: "month", TickMargin: 10, TickFormatter: chartExampleMonthShort, }) @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }} templ ChartExampleTooltip() { @chart.Container(chart.ContainerProps{ID: "chart-example-tooltip", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.XAxis(chart.XAxisProps{ DataKey: "month", TickMargin: 10, TickFormatter: chartExampleMonthShort, }) @chart.Tooltip() @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }} templ ChartExampleLegend() { @chart.Container(chart.ContainerProps{ID: "chart-example-legend", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.XAxis(chart.XAxisProps{ DataKey: "month", TickMargin: 10, TickFormatter: chartExampleMonthShort, }) @chart.Tooltip() @chart.Legend() @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }}Hover to see the tooltips. Easy, right? One component, and we’ve got a beautiful tooltip.
Add Legend
We’ll do the same for the legend. We’ll use the Legend component from chart.
Add the Legend component to your chart.
@chart.Container(chart.ContainerProps{ID: "chart-example", Config: chartConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.XAxis(chart.XAxisProps{ DataKey: "month", TickMargin: 10, TickFormatter: monthShort, }) @chart.Tooltip() @chart.Legend() @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) }}package examples import "github.com/axadrn/shadcn-templ/v2/components/chart"package examples import "github.com/axadrn/shadcn-templ/v2/components/chart" // The "Your First Chart" steps from the chart docs, the pendants of// chart-example.tsx and the grid, axis, tooltip and legend variants. var chartExampleData = []chart.Datum{ {"month": "January", "desktop": 186, "mobile": 80}, {"month": "February", "desktop": 305, "mobile": 200}, {"month": "March", "desktop": 237, "mobile": 120}, {"month": "April", "desktop": 73, "mobile": 190}, {"month": "May", "desktop": 209, "mobile": 130}, {"month": "June", "desktop": 214, "mobile": 140},} var chartExampleConfig = chart.Config{ {Key: "desktop", Label: "Desktop", Color: "#2563eb"}, {Key: "mobile", Label: "Mobile", Color: "#60a5fa"},} func chartExampleMonthShort(v any) string { return v.(string)[:3]} templ ChartExample() { @chart.Container(chart.ContainerProps{ID: "chart-example", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }} templ ChartExampleGrid() { @chart.Container(chart.ContainerProps{ID: "chart-example-grid", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }} templ ChartExampleAxis() { @chart.Container(chart.ContainerProps{ID: "chart-example-axis", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.XAxis(chart.XAxisProps{ DataKey: "month", TickMargin: 10, TickFormatter: chartExampleMonthShort, }) @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }} templ ChartExampleTooltip() { @chart.Container(chart.ContainerProps{ID: "chart-example-tooltip", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.XAxis(chart.XAxisProps{ DataKey: "month", TickMargin: 10, TickFormatter: chartExampleMonthShort, }) @chart.Tooltip() @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }} templ ChartExampleLegend() { @chart.Container(chart.ContainerProps{ID: "chart-example-legend", Config: chartExampleConfig, Class: "min-h-[200px] w-full"}) { @chart.BarChart(chart.BarChartProps{Data: chartExampleData}) { @chart.CartesianGrid(chart.CartesianGridProps{Vertical: chart.Bool(false)}) @chart.XAxis(chart.XAxisProps{ DataKey: "month", TickMargin: 10, TickFormatter: chartExampleMonthShort, }) @chart.Tooltip() @chart.Legend() @chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)", Radius: 4}) @chart.Bar(chart.BarProps{DataKey: "mobile", Fill: "var(--color-mobile)", Radius: 4}) } }}Done. You’ve built your first chart! What’s next?
Chart Config
The chart config is where you define the labels, icons and colors for a chart.
It is intentionally decoupled from chart data.
This allows you to share config and color tokens between charts. It can also work independently for cases where your data or color tokens live remotely or in a different format.
import "github.com/axadrn/shadcn-templ/v2/components/icon" var chartConfig = chart.Config{ { Key: "desktop", Label: "Desktop", Icon: icon.Monitor(), // A color like "hsl(220, 98%, 61%)" or "var(--color-name)" Color: "#2563eb", // OR a theme with light and dark colors Theme: &chart.SeriesTheme{ Light: "#2563eb", Dark: "#dc2626", }, },}Theming
Charts have built-in support for theming. You can use css variables (recommended) or color values in any color format, such as hex, hsl or oklch.
CSS Variables
Define your colors in your css file
@layer base { :root { --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); } .dark { --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); }}Add the color to your chartConfig
var chartConfig = chart.Config{ {Key: "desktop", Label: "Desktop", Color: "var(--chart-1)"}, {Key: "mobile", Label: "Mobile", Color: "var(--chart-2)"},}hex, hsl or oklch
You can also define your colors directly in the chart config. Use the color format you prefer.
var chartConfig = chart.Config{ {Key: "desktop", Label: "Desktop", Color: "#2563eb"}, {Key: "mobile", Label: "Mobile", Color: "hsl(220, 98%, 61%)"}, {Key: "tablet", Label: "Tablet", Color: "oklch(0.5 0.2 240)"}, {Key: "laptop", Label: "Laptop", Color: "var(--chart-2)"},}Using Colors
To use the theme colors in your chart, reference the colors using the format var(--color-KEY).
Components
@chart.Bar(chart.BarProps{DataKey: "desktop", Fill: "var(--color-desktop)"})Chart Data
var chartData = []chart.Datum{ {"browser": "chrome", "visitors": 275, "fill": "var(--color-chrome)"}, {"browser": "safari", "visitors": 200, "fill": "var(--color-safari)"},}Tailwind
Every Class prop takes Tailwind utility classes, so you can also color chart parts with the arbitrary property syntax.
@chart.LabelList(chart.LabelListProps{Class: "fill-(--color-desktop)"})Tooltip
A chart tooltip contains a label, name, indicator and value. You can use a combination of these to customize your tooltip.
The tooltip trails the cursor inside the plot area and snaps to the active data point.
You can turn the label and the indicator on and off using the HideLabel and HideIndicator props and customize the indicator style using the Indicator prop. Use LabelKey and NameKey to use a custom key for the tooltip label and name.
Chart comes with the Tooltip component and its TooltipContentProps, the pendant of ChartTooltip and ChartTooltipContent. You can use these to add tooltips to your chart.
@chart.Tooltip(chart.TooltipProps{ Cursor: chart.Bool(false), Content: chart.TooltipContentProps{Indicator: "line"},})Props
chart.TooltipProps
| Prop | Type | Description |
|---|---|---|
Cursor |
*bool | Hover cursor, defaults to on. |
Content |
TooltipContentProps | The rendered tooltip content. |
DefaultIndex |
*int | Shows the tooltip on mount at that category. |
chart.TooltipContentProps
| Prop | Type | Description |
|---|---|---|
Indicator |
dot line or dashed |
The indicator style for the tooltip. |
LabelKey |
string | Config key to use for the tooltip label. |
HideLabel |
bool | Whether to hide the label. |
HideIndicator |
bool | Whether to hide the indicator. |
NameKey |
string | Config key to use for the series name. |
LabelFormatter |
func(any) string | Formats the tooltip label. |
Formatter |
func(value any, name string, item chart.Datum, index int) templ.Component | Replaces a row’s default markup, rendered per data row. |
Class |
string | Extra classes for the tooltip content. |
LabelClassName |
string | Extra classes for the tooltip label. |
Color |
string | Overrides the indicator color for every row. |
Colors
Colors are automatically referenced from the chart config.
Custom
To use a custom key for tooltip label and names, use the LabelKey and NameKey props.
var chartData = []chart.Datum{ {"browser": "chrome", "visitors": 187, "fill": "var(--color-chrome)"}, {"browser": "safari", "visitors": 200, "fill": "var(--color-safari)"},} var chartConfig = chart.Config{ {Key: "visitors", Label: "Total Visitors"}, {Key: "chrome", Label: "Chrome", Color: "var(--chart-1)"}, {Key: "safari", Label: "Safari", Color: "var(--chart-2)"},}@chart.Tooltip(chart.TooltipProps{ Content: chart.TooltipContentProps{LabelKey: "visitors", NameKey: "browser"},})This will use Total Visitors for label and Chrome and Safari for the tooltip names.
Legend
You can use the Legend component to add a legend to your chart.
@chart.Legend()It renders the label and color of every series, and the colors are automatically referenced from the chart config. When a config entry sets an Icon, it replaces the color swatch.
Props
chart.LegendProps
| Prop | Type | Description |
|---|---|---|
NameKey |
string | Config key to use for the legend labels. |
Class |
string | Extra classes for the legend content. |
VerticalAlign |
string | top places the legend above the plot, everything else below. |
HideIcon |
bool | Drops the config icon and falls back to the color swatch. |
Custom
To use a custom key for the legend names, use the NameKey prop.
var chartData = []chart.Datum{ {"browser": "chrome", "visitors": 275, "fill": "var(--color-chrome)"}, {"browser": "safari", "visitors": 200, "fill": "var(--color-safari)"},} var chartConfig = chart.Config{ {Key: "chrome", Label: "Chrome", Color: "var(--chart-1)"}, {Key: "safari", Label: "Safari", Color: "var(--chart-2)"},}@chart.Legend(chart.LegendProps{NameKey: "browser"})This will use the config labels of the browser values for the legend names.
Accessibility
Set AccessibilityLayer: true on the chart root to enable the keyboard layer, the pendant of Recharts’ accessibilityLayer prop.
@chart.BarChart(chart.BarChartProps{AccessibilityLayer: true, Data: chartData}) { ...}The chart becomes focusable, focusing it shows the tooltip and the left and right arrow keys walk the tooltip through the categories.