histogram waveapp

This commit is contained in:
sawka
2024-11-25 14:41:24 -08:00
parent 712d306944
commit 619d06ea74
2 changed files with 516 additions and 0 deletions
+377
View File
@@ -0,0 +1,377 @@
package main
import (
"bufio"
"context"
_ "embed"
"flag"
"fmt"
"math"
"os"
"sort"
"strconv"
"strings"
"github.com/wavetermdev/waveterm/pkg/vdom"
"github.com/wavetermdev/waveterm/pkg/waveapp"
)
//go:embed style.css
var styleCSS []byte
// CLI flags
var (
numBuckets = flag.Int("b", 10, "initial number of buckets")
minValue = flag.Float64("min", math.NaN(), "minimum value (auto if not specified)")
maxValue = flag.Float64("max", math.NaN(), "maximum value (auto if not specified)")
)
var AppClient = waveapp.MakeClient(waveapp.AppOpts{
CloseOnCtrlC: true,
GlobalStyles: styleCSS,
})
type HistogramProps struct {
Values []float64 `json:"values"`
NumBuckets int `json:"numBuckets"`
MinValue *float64 `json:"minValue"`
MaxValue *float64 `json:"maxValue"`
}
type HistogramBucket struct {
Start float64
End float64
Count int
Height int
}
func calcStats(values []float64) (mean, median, stddev float64) {
if len(values) == 0 {
return 0, 0, 0
}
// Calculate mean
sum := 0.0
for _, v := range values {
sum += v
}
mean = sum / float64(len(values))
// Calculate median
sorted := make([]float64, len(values))
copy(sorted, values)
sort.Float64s(sorted)
if len(sorted)%2 == 0 {
median = (sorted[len(sorted)/2-1] + sorted[len(sorted)/2]) / 2
} else {
median = sorted[len(sorted)/2]
}
// Calculate standard deviation
sumSquares := 0.0
for _, v := range values {
diff := v - mean
sumSquares += diff * diff
}
stddev = math.Sqrt(sumSquares / float64(len(values)))
return mean, median, stddev
}
var Histogram = waveapp.DefineComponent[HistogramProps](AppClient, "Histogram",
func(ctx context.Context, props HistogramProps) any {
if len(props.Values) == 0 {
return vdom.H("div", map[string]any{
"className": "histogram-empty",
}, "Waiting for data...")
}
// Calculate data range and stats
min, max := props.Values[0], props.Values[0]
for _, v := range props.Values {
if v < min {
min = v
}
if v > max {
max = v
}
}
mean, median, stddev := calcStats(props.Values)
// Use provided min/max if set
bucketMin := min
if props.MinValue != nil {
bucketMin = *props.MinValue
}
bucketMax := max
if props.MaxValue != nil {
bucketMax = *props.MaxValue
}
// Create evenly spaced buckets
bucketSize := (bucketMax - bucketMin) / float64(props.NumBuckets)
buckets := make([]HistogramBucket, props.NumBuckets)
// Initialize bucket ranges
for i := range buckets {
buckets[i].Start = bucketMin + float64(i)*bucketSize
buckets[i].End = buckets[i].Start + bucketSize
}
// Fill buckets
maxCount := 0
for _, v := range props.Values {
bucketValue := v
if props.MinValue != nil && v < *props.MinValue {
bucketValue = *props.MinValue
}
if props.MaxValue != nil && v > *props.MaxValue {
bucketValue = *props.MaxValue
}
bucketIdx := int((bucketValue - bucketMin) / bucketSize)
if bucketIdx >= len(buckets) {
bucketIdx = len(buckets) - 1
}
if bucketIdx >= 0 && bucketIdx < len(buckets) {
buckets[bucketIdx].Count++
if buckets[bucketIdx].Count > maxCount {
maxCount = buckets[bucketIdx].Count
}
}
}
// Normalize heights
const maxHeight = 20
for i := range buckets {
if maxCount > 0 {
buckets[i].Height = (buckets[i].Count * maxHeight) / maxCount
}
}
return vdom.H("div", map[string]any{
"className": "histogram",
},
// Stats header
vdom.H("div", map[string]any{
"className": "histogram-stats",
},
"Count: ", len(props.Values),
" | Range: ", strconv.FormatFloat(min, 'f', 2, 64),
" - ", strconv.FormatFloat(max, 'f', 2, 64),
" | Mean: ", strconv.FormatFloat(mean, 'f', 2, 64),
" | Median: ", strconv.FormatFloat(median, 'f', 2, 64),
" | StdDev: ", strconv.FormatFloat(stddev, 'f', 2, 64),
),
// Single scrolling container for both bars and labels
vdom.H("div", map[string]any{
"className": "histogram-scroll-container",
},
// Container for all columns and final label
vdom.H("div", map[string]any{
"className": "histogram-content",
},
// Generate columns (bars + labels)
vdom.ForEachIdx(buckets, func(bucket HistogramBucket, idx int) any {
return vdom.H("div", map[string]any{
"key": idx,
"className": "histogram-column",
},
// Count label (visible on hover)
vdom.H("div", map[string]any{
"className": "count-label",
}, strconv.Itoa(bucket.Count)),
// Bar
vdom.H("div", map[string]any{
"className": vdom.Classes(
"bar",
vdom.If(bucket.Count == 0, "empty"),
),
"style": map[string]any{
"height": func() string {
if bucket.Count == 0 {
return "1px"
}
return fmt.Sprintf("%dpx", bucket.Height*8)
}(),
},
}),
// Label for left boundary
vdom.H("div", map[string]any{
"className": "x-label",
}, strconv.FormatFloat(bucket.Start, 'f', 1, 64)),
)
}),
// Final label for rightmost boundary
vdom.H("div", map[string]any{
"className": "histogram-final-label",
},
vdom.H("div", map[string]any{
"className": "x-label",
}, strconv.FormatFloat(bucketMax, 'f', 1, 64)),
),
),
),
)
},
)
var App = waveapp.DefineComponent(AppClient, "App",
func(ctx context.Context, _ any) any {
// Initialize with CLI values
initialMin := (*float64)(nil)
if !math.IsNaN(*minValue) {
initialMin = minValue
}
initialMax := (*float64)(nil)
if !math.IsNaN(*maxValue) {
initialMax = maxValue
}
values, _, setValuesFn := vdom.UseStateWithFn(ctx, []float64{})
numBuckets, setNumBuckets := vdom.UseState(ctx, *numBuckets)
minValue, setMinValue := vdom.UseState(ctx, (*float64)(initialMin))
maxValue, setMaxValue := vdom.UseState(ctx, (*float64)(initialMax))
vdom.UseEffect(ctx, func() func() {
done := make(chan bool)
go func() {
defer close(done)
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if num, err := strconv.ParseFloat(line, 64); err == nil {
setValuesFn(func(currentValues []float64) []float64 {
newValues := make([]float64, len(currentValues)+1)
copy(newValues, currentValues)
newValues[len(currentValues)] = num
return newValues
})
AppClient.SendAsyncInitiation()
}
}
}()
return func() {
<-done
}
}, []any{})
return vdom.H("div", map[string]any{
"className": "app",
},
vdom.H("div", map[string]any{
"className": "controls",
},
vdom.H("div", map[string]any{
"className": "control-group",
},
vdom.H("label", nil, "Number of buckets: "),
vdom.H("input", map[string]any{
"type": "text",
"value": numBuckets,
"onChange": func(e vdom.VDomEvent) {
if n, err := strconv.Atoi(e.TargetValue); err == nil && n >= 2 && n <= 100 {
setNumBuckets(n)
} else {
setNumBuckets(10)
}
},
}),
),
vdom.H("div", map[string]any{
"className": "control-group",
},
vdom.H("label", nil, "Min value: "),
vdom.H("input", map[string]any{
"type": "text",
"value": func() string {
if minValue != nil {
return strconv.FormatFloat(*minValue, 'f', -1, 64)
}
return ""
}(),
"placeholder": "auto",
"onChange": func(e vdom.VDomEvent) {
if e.TargetValue == "" {
setMinValue(nil)
} else if val, err := strconv.ParseFloat(e.TargetValue, 64); err == nil {
setMinValue(&val)
}
},
}),
vdom.If(minValue != nil,
vdom.H("button", map[string]any{
"onClick": func() { setMinValue(nil) },
"className": "clear-btn",
}, "×"),
),
),
vdom.H("div", map[string]any{
"className": "control-group",
},
vdom.H("label", nil, "Max value: "),
vdom.H("input", map[string]any{
"type": "text",
"value": func() string {
if maxValue != nil {
return strconv.FormatFloat(*maxValue, 'f', -1, 64)
}
return ""
}(),
"placeholder": "auto",
"onChange": func(e vdom.VDomEvent) {
if e.TargetValue == "" {
setMaxValue(nil)
} else if val, err := strconv.ParseFloat(e.TargetValue, 64); err == nil {
setMaxValue(&val)
}
},
}),
vdom.If(maxValue != nil,
vdom.H("button", map[string]any{
"onClick": func() { setMaxValue(nil) },
"className": "clear-btn",
}, "×"),
),
),
),
Histogram(HistogramProps{
Values: values,
NumBuckets: numBuckets,
MinValue: minValue,
MaxValue: maxValue,
}),
)
},
)
func main() {
// Register WaveApp's flags first
AppClient.RegisterDefaultFlags()
// Parse our flags
flag.Parse()
if *numBuckets < 2 {
fmt.Fprintf(os.Stderr, "Number of buckets must be at least 2\n")
os.Exit(1)
}
if *numBuckets > 100 {
fmt.Fprintf(os.Stderr, "Number of buckets must be at most 100\n")
os.Exit(1)
}
AppClient.RunMain()
}
+139
View File
@@ -0,0 +1,139 @@
.app {
padding: 20px;
font-family: system-ui, -apple-system, sans-serif;
}
/* Controls */
.controls {
margin-bottom: 20px;
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.control-group {
display: flex;
align-items: center;
gap: 8px;
}
.control-group input {
padding: 4px 8px;
width: 80px;
background: transparent;
border: 1px solid #666;
border-radius: 4px;
color: white;
}
.control-group input::placeholder {
color: #666;
}
.clear-btn {
padding: 2px 6px;
border-radius: 4px;
border: 1px solid #666;
background: transparent;
color: #666;
cursor: pointer;
}
.clear-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
/* Histogram */
.histogram-empty {
color: white;
font-style: italic;
padding: 20px 0;
}
.histogram-stats {
margin-bottom: 16px;
color: white;
}
.histogram-scroll-container {
overflow-x: auto;
margin-bottom: 40px;
background: rgba(0, 0, 0, 0.1);
padding: 10px;
border-radius: 4px;
}
.histogram-content {
display: flex;
align-items: flex-end;
height: 240px; /* Increased to accommodate labels */
gap: 2px;
}
.histogram-column {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
min-width: 30px;
position: relative;
}
.bar {
width: 100%;
background: #2563eb;
transition: background-color 0.2s ease;
border-radius: 2px 2px 0 0;
margin-bottom: 40px; /* Space for label */
}
.bar.empty {
background: rgba(37, 99, 235, 0.2);
}
.histogram-column:hover .bar {
background: #22c55e;
}
.histogram-column:hover .bar.empty {
background: rgba(34, 197, 94, 0.2);
}
.count-label {
position: absolute;
top: -24px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
opacity: 0;
transform: translateY(4px);
transition: all 0.2s ease;
pointer-events: none;
}
.histogram-column:hover .count-label {
opacity: 1;
transform: translateY(0);
}
.x-label {
color: white;
font-size: 11px;
transform: rotate(-45deg);
transform-origin: left top;
white-space: nowrap;
position: absolute;
bottom: 10px;
left: -8px; /* Align to left edge of column */
}
.histogram-final-label {
min-width: 30px;
position: relative;
}
.histogram-final-label .x-label {
left: -8px; /* Align to left edge of final label div */
}