diff --git a/go/jsruntime/basic.ts b/go/jsruntime/basic.ts index cac01479..3aeab9bf 100644 --- a/go/jsruntime/basic.ts +++ b/go/jsruntime/basic.ts @@ -53,4 +53,8 @@ export function numberToStringWithCommas(n: number):string { } return result; +} + +export function clamp(val: number, min: number, max: number) : number { + return Math.min(Math.max(val, min), max); } \ No newline at end of file diff --git a/go/jsruntime/uikit/Chart.tsx b/go/jsruntime/uikit/Chart.tsx index 2883eb1e..2522bae0 100644 --- a/go/jsruntime/uikit/Chart.tsx +++ b/go/jsruntime/uikit/Chart.tsx @@ -48,10 +48,14 @@ export interface ChartProps { // donut only: inner-radius fraction of the outer radius (0.6 by default). donutRatio?: number; + // Controls border/gap between pie/doughnut segments (acts as a per-slice & full pie "stroke") + segmentGap?: number; // value in px (default 2) + // Give the chart depth: bars extrude, pie/donut tilt and gain a rim (line/area ignore // it). An embellishment — flat reads more precisely — but sometimes wanted. threeD?: boolean; depth?: number; // 3D extrusion depth in px (default 16). + tilt?: number; // 3D tilt scalar (0 -> full tilt, 1 -> fully flattened) height?: number; // px of the plot area (default 300). The legend adds its own height. width?: number; // fix the width instead of measuring the container. @@ -83,7 +87,6 @@ const DEFAULT_HEIGHT = 300; const DEFAULT_WIDTH = 640; // used only until the container is measured (and on the server) const BAR_MAX_W = 24; // cap a bar's thickness; the band's leftover is deliberate air const BAR_RADIUS = 4; // rounded data-end -const SEG_GAP = 2; // the surface gap between touching marks (stacked segments) const MARK_R = 4; // hover marker radius (8px mark) const DEFAULT_DEPTH = 16; // 3D extrusion depth @@ -410,7 +413,7 @@ function CartesianChart(p: SubProps): JSXElement { const to = from + v; if (v >= 0) accPos = to; else accNeg = to; const isEnd = (v > 0 && s === lastPos) || (v < 0 && s === lastNeg); - const inset = isEnd ? 0 : SEG_GAP; // 2px surface gap between segments + const inset = isEnd ? 0 : (p.props.segmentGap ?? 2); const vFrom = valuePos(from); // pull the value end toward the baseline by the gap (except the outer end) const vTo = valuePos(to) + (h ? (v >= 0 ? -inset : inset) : (v >= 0 ? inset : -inset)); @@ -424,8 +427,8 @@ function CartesianChart(p: SubProps): JSXElement { // original series index. const vis = series.map((_, s) => s).filter(shown); const nS = Math.max(1, vis.length); - const groupSize = Math.min(bf * 0.72, (BAR_MAX_W + SEG_GAP) * nS); - const each = Math.max(1, Math.min(BAR_MAX_W, groupSize / nS - SEG_GAP)); + const groupSize = Math.min(bf * 0.72, (BAR_MAX_W + (p.props.segmentGap ?? 2)) * nS); + const each = Math.max(1, Math.min(BAR_MAX_W, groupSize / nS - (p.props.segmentGap ?? 2))); for (let i = 0; i < count; i++) { const g = catStart() + bf * i + (bf - groupSize) / 2; vis.forEach((s, j) => { @@ -610,7 +613,7 @@ function RadialChart(p: SubProps): JSXElement { const threeD = () => !!p.props.threeD; const depth = () => p.props.depth ?? DEFAULT_DEPTH; - const tilt = () => (threeD() ? 0.62 : 1); // vertical squash of the disc when tilted + const tilt = () => (threeD() ? clamp((p.props.tilt ?? 0.85), 0, 1) : 1); // vertical squash of the disc when tilted const values = () => p.props.series[0]?.data ?? []; const labels = () => p.props.labels ?? values().map((_, i) => String(i + 1)); @@ -679,7 +682,7 @@ function RadialChart(p: SubProps): JSXElement { for (const s of slices()) { if (s.a1 <= s.a0) continue; const op = hv === null || hv === s.idx ? 1 : 0.55; - out.push(``); + out.push(``); } } return out.join(""); diff --git a/go/webui/chart.go b/go/webui/chart.go index b9fc60be..46b44f1d 100644 --- a/go/webui/chart.go +++ b/go/webui/chart.go @@ -56,8 +56,15 @@ type ChartProps struct { Horizontal bool // bar: categories down the y-axis, values along x Smooth bool // line/area: Catmull-Rom spline DonutRatio float64 + // SegmentGap is the px gap/stroke between touching marks — pie/donut slices (a per-slice + // and full-pie stroke) and stacked/grouped bar segments. A pointer so an explicit 0 (no + // gap) is distinct from unset; nil defaults to 2. + 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 *float64 Width float64 // internal viewBox width (default 640); the SVG scales to its container Height float64 // default 300 @@ -89,6 +96,7 @@ const ( chartSegGap = 2.0 chartMarkR = 4.0 chartDefaultDepth = 16.0 + chartDefaultTilt = 0.85 // 3D pie/donut vertical squash (0 → edge-on, 1 → flat) ) var chartTokens = []string{ @@ -388,6 +396,15 @@ func chartDepth(p ChartProps) float64 { } return chartDefaultDepth } + +// chartSegGapPx is the surface gap between touching marks: the SegmentGap prop when set +// (including an explicit 0), else the 2px default. +func chartSegGapPx(p ChartProps) float64 { + if p.SegmentGap != nil { + return *p.SegmentGap + } + return chartSegGap +} func chartDX(p ChartProps) float64 { if chart3D(p) { return chartDepth(p) * 0.7 @@ -639,7 +656,7 @@ func chartBars(p ChartProps, w, h float64) []barMark { accNeg = to } isEnd := (v > 0 && s == lastPos) || (v < 0 && s == lastNeg) - inset := chartSegGap + inset := chartSegGapPx(p) if isEnd { inset = 0 } @@ -672,8 +689,9 @@ func chartBars(p ChartProps, w, h float64) []barMark { } } nS := maxi(1, len(vis)) - groupSize := math.Min(bf*0.72, (chartBarMaxW+chartSegGap)*float64(nS)) - each := math.Max(1, math.Min(chartBarMaxW, groupSize/float64(nS)-chartSegGap)) + gap := chartSegGapPx(p) + groupSize := math.Min(bf*0.72, (chartBarMaxW+gap)*float64(nS)) + each := math.Max(1, math.Min(chartBarMaxW, groupSize/float64(nS)-gap)) for i := 0; i < count; i++ { g := catStart(p, w, h) + bf*float64(i) + (bf-groupSize)/2 for j, s := range vis { @@ -859,10 +877,14 @@ func labelAt(p ChartProps, i int) string { // ── radial body ───────────────────────────────────────────────────────────────── func radialTilt(p ChartProps) float64 { - if p.ThreeD { - return 0.62 + if !p.ThreeD { + return 1 // no tilt: a flat, face-on disc } - return 1 + t := chartDefaultTilt + if p.Tilt != nil { + t = *p.Tilt + } + return clampf(t, 0, 1) // vertical squash of the disc when tilted } type radialGeo struct{ cx, cy, rOut, rIn, k float64 } @@ -1007,7 +1029,7 @@ func radialBody(p ChartProps, w, h float64, hv int) string { if hv >= 0 && hv != s.idx { op = 0.55 } - b.WriteString(``) + b.WriteString(``) } }