update 3d chart visualization

This commit is contained in:
2026-07-20 16:28:55 -04:00
parent b9864864a6
commit ee84f10cf0
4 changed files with 290 additions and 28 deletions

View File

@@ -62,8 +62,11 @@ type ChartProps struct {
SegmentGap *float64
ThreeD bool // extrude bars, tilt pie/donut (ignored on line/area)
Depth float64
// Tilt is the 3D pie/donut squash scalar: 0 → full tilt (edge-on), 1 flat. A pointer so
// an explicit 0 is distinct from unset; nil defaults to 0.85.
// Tilt is the 3D perspective scalar, read differently per kind but always "1 = flattest,
// 0 = most tilted". For a bar it is the depth-axis angle — 1 extrudes head-on (barely any
// rise, the grid reads almost flat), 0 rotates to a bird's-eye view (the floor opens up);
// nil defaults to 0.6. For pie/donut it is the disc squash — 0 edge-on, 1 face-on flat;
// nil defaults to 0.85. A pointer so an explicit 0 is distinct from unset.
Tilt *float64
Width float64 // internal viewBox width (default 640); the SVG scales to its container
@@ -97,6 +100,7 @@ const (
chartMarkR = 4.0
chartDefaultDepth = 16.0
chartDefaultTilt = 0.85 // 3D pie/donut vertical squash (0 → edge-on, 1 → flat)
chartDefaultTiltC = 0.6 // 3D bar depth-axis (1 → head-on/flat, 0 → bird's-eye)
)
var chartTokens = []string{
@@ -405,15 +409,39 @@ func chartSegGapPx(p ChartProps) float64 {
}
return chartSegGap
}
// cartTilt is the 3D bar depth-axis scalar (1 → head-on, 0 → bird's-eye). It shares the
// Tilt prop with the radial squash, but a chart is only ever one kind so the two readings
// never collide.
func cartTilt(p ChartProps) float64 {
t := chartDefaultTiltC
if p.Tilt != nil {
t = *p.Tilt
}
return clampf(t, 0, 1)
}
// chartGrid3D reports whether the extruded grid (floor + back wall) is drawn: any cartesian
// kind with ThreeD set. Bars additionally extrude along the depth axis (see chart3D); a
// line/area is merely shifted onto the back wall so it tracks the wall's gridlines — the
// trace itself is never extruded, since depth on a 1px stroke reads as noise.
func chartGrid3D(p ChartProps) bool {
return p.ThreeD && !radial(p.Kind)
}
// chartDX/chartDY are the (right, up) components of the 3D depth axis — the vector bars
// extrude along, the grid is swept along, and a line/area is shifted onto the back wall by,
// so they all read as one 3D space. Its length is Depth; Tilt turns it from head-on (all
// run, no rise) toward bird's-eye (all rise, no run).
func cartDepthAngle(p ChartProps) float64 { return (1 - cartTilt(p)) * (math.Pi / 2) }
func chartDX(p ChartProps) float64 {
if chart3D(p) {
return chartDepth(p) * 0.7
if chartGrid3D(p) {
return chartDepth(p) * math.Cos(cartDepthAngle(p))
}
return 0
}
func chartDY(p ChartProps) float64 {
if chart3D(p) {
return chartDepth(p) * 0.55
if chartGrid3D(p) {
return chartDepth(p) * math.Sin(cartDepthAngle(p))
}
return 0
}
@@ -731,6 +759,9 @@ func chartPaths(p ChartProps, w, h float64) []lineMark {
}
count := chartN(p)
base := baseValue(p, w, h)
// The trace stays on the FRONT plane in 3D — it draws last, on top of the extruded grid,
// and aligns with the front value axis and its front gridlines. Only the grid (floor +
// back wall) carries the depth; the stroke is never extruded.
stackAcc := make([]float64, count)
out := make([]lineMark, 0, len(p.Series))
for si, s := range p.Series {
@@ -781,9 +812,16 @@ func cartesianBody(p ChartProps, w, h float64, hv int) string {
showGrid := !p.NoGrid
var b strings.Builder
// The extruded grid (floor + back wall) is drawn under everything when 3D. Front
// gridlines are kept for a 3D line/area (the trace lives on the front plane and reads
// against them) but dropped for a 3D bar (bars occlude the front and meet the back wall).
flatGrid := showGrid && (!chartGrid3D(p) || p.Kind != ChartBar)
if showGrid && chartGrid3D(p) {
b.WriteString(cartesian3DGrid(p, w, h))
}
for _, t := range d.ticks {
vp := valuePos(p, w, h, t)
if showGrid {
if flatGrid {
if hz {
b.WriteString(`<line x1="` + num(vp) + `" x2="` + num(vp) + `" y1="` + num(l.top) + `" y2="` + num(l.top+l.plotH) + `" stroke="var(--color-line)" stroke-width="1"/>`)
} else {
@@ -817,14 +855,14 @@ func cartesianBody(p ChartProps, w, h float64, hv int) string {
}
isBar := p.Kind == ChartBar
// crosshair (line/area only)
dx, dy := chartDX(p), chartDY(p)
// crosshair (line/area only) — on the front plane, where the trace is.
if !p.NoTooltip && hv >= 0 && !isBar {
c := catCenter(p, w, h, hv)
b.WriteString(`<line x1="` + num(c) + `" x2="` + num(c) + `" y1="` + num(l.top) + `" y2="` + num(l.top+l.plotH) + `" stroke="var(--color-line-strong)" stroke-width="1"/>`)
}
// bars
dx, dy := chartDX(p), chartDY(p)
for _, bm := range chartBars(p, w, h) {
op := 1.0
if hv >= 0 && hv != bm.cat {
@@ -866,6 +904,69 @@ func cartesianBody(p ChartProps, w, h float64, hv int) string {
return b.String()
}
// cartesian3DGrid draws the extruded grid a 3D bar chart stands in: a floor swept back from
// the value-0 baseline and a back wall carrying the value gridlines, connected by receding
// lines at each category boundary and each gridline's labelled end. It uses the SAME
// (dx,-dy) depth axis the bars extrude along, so the grid and the columns read as one 3D
// space instead of flat rules behind floating blocks. Drawn before the bars, which paint
// over the parts of the floor/wall they occlude.
func cartesian3DGrid(p ChartProps, w, h float64) string {
l := chartLayout(p, w, h)
d := chartDomain(p)
dx, dy := chartDX(p), chartDY(p)
hz := chartHoriz(p)
n := chartN(p)
bv := baseValue(p, w, h)
var b strings.Builder
// a filled parallelogram (a plane), faint and themed so it grounds without adding lines
quad := func(x1, y1, x2, y2, x3, y3, x4, y4 float64, op string) {
b.WriteString(`<path d="M` + num(x1) + `,` + num(y1) + ` L` + num(x2) + `,` + num(y2) +
` L` + num(x3) + `,` + num(y3) + ` L` + num(x4) + `,` + num(y4) +
` Z" fill="var(--color-line)" fill-opacity="` + op + `"/>`)
}
// a grid line at a given stroke-opacity (receding edges are drawn fainter than the wall)
seg := func(x1, y1, x2, y2 float64, sop string) {
b.WriteString(`<line x1="` + num(x1) + `" y1="` + num(y1) + `" x2="` + num(x2) + `" y2="` + num(y2) +
`" stroke="var(--color-line)" stroke-width="1" stroke-opacity="` + sop + `"/>`)
}
if hz {
yA, yB := l.top, l.top+l.plotH
xA, xB := l.left, l.left+l.plotW
quad(bv, yA, bv, yB, bv+dx, yB-dy, bv+dx, yA-dy, "0.09") // floor (baseline plane)
quad(xA+dx, yA-dy, xB+dx, yA-dy, xB+dx, yB-dy, xA+dx, yB-dy, "0.05") // back wall
for _, t := range d.ticks {
vp := valuePos(p, w, h, t)
seg(vp+dx, yA-dy, vp+dx, yB-dy, "1") // back-wall value gridline
seg(vp, yB, vp+dx, yB-dy, "0.5") // connector at the labelled (bottom) edge
}
band := l.plotH / maxf(1, float64(n))
for i := 0; i <= n; i++ {
yc := l.top + band*float64(i)
seg(bv, yc, bv+dx, yc-dy, "0.5") // floor line receding at each category boundary
}
seg(bv+dx, yA-dy, bv+dx, yB-dy, "1") // far edge of the floor / foot of the back wall
} else {
xA, xB := l.left, l.left+l.plotW
yT, yB := l.top, l.top+l.plotH
quad(xA, bv, xB, bv, xB+dx, bv-dy, xA+dx, bv-dy, "0.09") // floor (baseline plane)
quad(xA+dx, yT-dy, xB+dx, yT-dy, xB+dx, yB-dy, xA+dx, yB-dy, "0.05") // back wall
for _, t := range d.ticks {
vp := valuePos(p, w, h, t)
seg(xA+dx, vp-dy, xB+dx, vp-dy, "1") // back-wall value gridline
seg(xA, vp, xA+dx, vp-dy, "0.5") // connector at the labelled (left) edge
}
band := l.plotW / maxf(1, float64(n))
for i := 0; i <= n; i++ {
xc := l.left + band*float64(i)
seg(xc, bv, xc+dx, bv-dy, "0.5") // floor line receding at each category boundary
}
seg(xA+dx, bv-dy, xB+dx, bv-dy, "1") // far edge of the floor / foot of the back wall
}
return b.String()
}
func labelAt(p ChartProps, i int) string {
ls := chartLabels(p)
if i >= 0 && i < len(ls) {