// StockGrid demonstrates an ag-grid-like data table using Via framework.
// Features: sorting, filtering, pagination - all in pure Go with no JavaScript.
//
// go run .
package main
import (
"log"
"math/rand"
"net"
"net/http"
"sort"
"strings"
"syscall"
"time"
"github.com/go-via/via"
"github.com/go-via/via/h"
"github.com/go-via/via/on"
)
type Stock struct {
Symbol string
Name string
Price float64
Change float64
ChangePercent float64
Volume int64
}
// allStocks contains sample stock data
var allStocks = []Stock{
{"AAPL", "Apple Inc.", 178.52, 2.35, 1.34, 58234567},
{"MSFT", "Microsoft Corporation", 378.91, -1.23, -0.32, 23456789},
{"GOOGL", "Alphabet Inc.", 141.80, 3.45, 2.49, 45678901},
{"AMZN", "Amazon.com Inc.", 178.25, 4.12, 2.37, 34567890},
{"NVDA", "NVIDIA Corporation", 495.22, 12.45, 2.58, 78901234},
{"META", "Meta Platforms Inc.", 505.95, -3.21, -0.63, 23456789},
{"TSLA", "Tesla Inc.", 177.48, -5.67, -3.10, 89012345},
{"BRK.B", "Berkshire Hathaway", 363.54, 1.23, 0.34, 12345678},
{"JPM", "JPMorgan Chase & Co.", 196.54, 2.11, 1.09, 34567890},
{"JNJ", "Johnson & Johnson", 156.74, -0.45, -0.29, 23456789},
{"V", "Visa Inc.", 276.32, 1.89, 0.69, 18901234},
{"PG", "Procter & Gamble", 158.90, 0.56, 0.35, 15678901},
{"UNH", "UnitedHealth Group", 527.43, -2.34, -0.44, 12345678},
{"HD", "Home Depot Inc.", 345.67, 4.23, 1.24, 8901234},
{"MA", "Mastercard Inc.", 458.90, 3.45, 0.76, 8901234},
{"DIS", "Walt Disney Company", 112.45, -1.56, -1.37, 23456789},
{"PYPL", "PayPal Holdings", 63.78, 2.34, 3.81, 34567890},
{"NFLX", "Netflix Inc.", 628.90, 15.67, 2.56, 8901234},
{"ADBE", "Adobe Inc.", 524.67, 8.90, 1.73, 4567890},
{"CRM", "Salesforce Inc.", 272.34, -3.45, -1.25, 12345678},
{"INTC", "Intel Corporation", 31.45, 0.78, 2.54, 56789012},
{"AMD", "Advanced Micro Devices", 158.90, 5.67, 3.70, 45678901},
{"CSCO", "Cisco Systems", 48.90, 0.34, 0.70, 23456789},
{"PEP", "PepsiCo Inc.", 172.56, 1.23, 0.72, 8901234},
{"KO", "Coca-Cola Company", 60.45, 0.12, 0.20, 23456789},
{"MRK", "Merck & Co.", 126.78, -0.89, -0.70, 15678901},
{"ABBV", "AbbVie Inc.", 173.45, 2.34, 1.37, 12345678},
{"TMO", "Thermo Fisher Scientific", 567.89, -4.56, -0.80, 3456789},
{"COST", "Costco Wholesale", 728.90, 12.34, 1.72, 4567890},
{"AVGO", "Broadcom Inc.", 1289.45, 45.67, 3.67, 5678901},
}
const pageSize = 10
type StockGrid struct {
SearchText via.SignalStr `via:"search,init="`
SortColumn via.StateTabStr `via:"sort,init=symbol"`
SortDir via.StateTabStr `via:"dir,init=asc"`
CurrentPage via.StateTab[int] `via:"page,init=1"`
TargetPage via.SignalNum[int] `via:"targetPage"`
AllStocks via.StateTabSlice[Stock]
}
func (s *StockGrid) OnInit(ctx *via.Ctx) error {
return s.AllStocks.Update(ctx, func(_ []Stock) ([]Stock, error) {
return allStocks, nil
})
}
func (s *StockGrid) OnConnect(ctx *via.Ctx) error {
via.Stream(ctx, 5*time.Second, func(ctx *via.Ctx, t time.Time) {
rng := rand.New(rand.NewSource(t.UnixNano()))
s.AllStocks.Update(ctx, func(stocks []Stock) ([]Stock, error) {
updated := make([]Stock, len(stocks))
for i, stock := range stocks {
rng.Seed(t.UnixNano() + int64(i))
change := (rng.Float64() - 0.5) * 0.10
newPrice := stock.Price * (1 + change)
newChange := newPrice - stock.Price
newChangePercent := change * 100
updated[i] = Stock{
Symbol: stock.Symbol,
Name: stock.Name,
Price: newPrice,
Change: newChange,
ChangePercent: newChangePercent,
Volume: stock.Volume + int64(rng.Intn(10000)),
}
}
return updated, nil
})
})
return nil
}
func (s *StockGrid) gotoPage(ctx *via.Ctx, page int) {
search := strings.ToLower(strings.TrimSpace(s.SearchText.Read(ctx)))
stocks := s.AllStocks.Read(ctx)
filtered := s.filterStocks(stocks, search)
totalPages := (len(filtered) + pageSize - 1) / pageSize
if page >= 1 && page <= totalPages {
s.CurrentPage.Write(ctx, page)
}
}
func (s *StockGrid) GoToPage(ctx *via.Ctx) {
page := s.TargetPage.Read(ctx)
s.gotoPage(ctx, page)
}
func (s *StockGrid) Search(ctx *via.Ctx) {
s.CurrentPage.Write(ctx, 1)
}
func (s *StockGrid) setSortCol(ctx *via.Ctx, col string) {
currentCol := s.SortColumn.Read(ctx)
currentDir := s.SortDir.Read(ctx)
if currentCol == col {
if currentDir == "asc" {
s.SortDir.Write(ctx, "desc")
} else {
s.SortDir.Write(ctx, "asc")
}
} else {
s.SortColumn.Write(ctx, col)
s.SortDir.Write(ctx, "asc")
}
}
func (s *StockGrid) ColSymbol(ctx *via.Ctx) { s.setSortCol(ctx, "symbol") }
func (s *StockGrid) ColName(ctx *via.Ctx) { s.setSortCol(ctx, "name") }
func (s *StockGrid) ColPrice(ctx *via.Ctx) { s.setSortCol(ctx, "price") }
func (s *StockGrid) ColChange(ctx *via.Ctx) { s.setSortCol(ctx, "change") }
func (s *StockGrid) ColChangePercent(ctx *via.Ctx) { s.setSortCol(ctx, "changePercent") }
func (s *StockGrid) ColVolume(ctx *via.Ctx) { s.setSortCol(ctx, "volume") }
func (s *StockGrid) PrevPage(ctx *via.Ctx) {
page := s.CurrentPage.Read(ctx)
if page > 1 {
s.CurrentPage.Write(ctx, page-1)
}
}
func (s *StockGrid) NextPage(ctx *via.Ctx) {
page := s.CurrentPage.Read(ctx)
search := strings.ToLower(strings.TrimSpace(s.SearchText.Read(ctx)))
stocks := s.AllStocks.Read(ctx)
filtered := s.filterStocks(stocks, search)
totalPages := (len(filtered) + pageSize - 1) / pageSize
if page < totalPages {
s.CurrentPage.Write(ctx, page+1)
}
}
func (s *StockGrid) filterStocks(stocks []Stock, search string) []Stock {
if search == "" {
return stocks
}
filtered := make([]Stock, 0)
for _, stock := range stocks {
if strings.Contains(strings.ToLower(stock.Symbol), search) ||
strings.Contains(strings.ToLower(stock.Name), search) {
filtered = append(filtered, stock)
}
}
return filtered
}
func (s *StockGrid) GetFilteredStocks(ctx *via.CtxR) []Stock {
search := strings.ToLower(strings.TrimSpace(s.SearchText.Read(ctx)))
stocks := s.AllStocks.Read(ctx)
filtered := s.filterStocks(stocks, search)
col := s.SortColumn.Read(ctx)
dir := s.SortDir.Read(ctx)
sort.Slice(filtered, func(i, j int) bool {
var less bool
switch col {
case "symbol":
less = filtered[i].Symbol < filtered[j].Symbol
case "name":
less = filtered[i].Name < filtered[j].Name
case "price":
less = filtered[i].Price < filtered[j].Price
case "change":
less = filtered[i].Change < filtered[j].Change
case "changePercent":
less = filtered[i].ChangePercent < filtered[j].ChangePercent
case "volume":
less = filtered[i].Volume < filtered[j].Volume
default:
less = filtered[i].Symbol < filtered[j].Symbol
}
if dir == "desc" {
return !less
}
return less
})
return filtered
}
func (s *StockGrid) GetPageStocks(ctx *via.CtxR) []Stock {
all := s.GetFilteredStocks(ctx)
page := s.CurrentPage.Read(ctx)
if page < 1 {
page = 1
}
start := (page - 1) * pageSize
end := start + pageSize
if start >= len(all) {
return []Stock{}
}
if end > len(all) {
end = len(all)
}
if start < 0 {
start = 0
}
return all[start:end]
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func (s *StockGrid) sortTh(col, label string, ctx *via.CtxR) h.H {
currentCol := s.SortColumn.Read(ctx)
currentDir := s.SortDir.Read(ctx)
isSorted := currentCol == col
icon := "▲"
if isSorted && currentDir == "desc" {
icon = "▼"
}
if col == "symbol" {
return h.Th(h.If(isSorted, h.Class("sorted")), on.Click(s.ColSymbol), h.Text(label), h.Span(h.Class("sort-icon"), h.Text(icon)))
}
if col == "name" {
return h.Th(h.If(isSorted, h.Class("sorted")), on.Click(s.ColName), h.Text(label), h.Span(h.Class("sort-icon"), h.Text(icon)))
}
if col == "price" {
return h.Th(h.If(isSorted, h.Class("sorted")), on.Click(s.ColPrice), h.Text(label), h.Span(h.Class("sort-icon"), h.Text(icon)))
}
if col == "change" {
return h.Th(h.If(isSorted, h.Class("sorted")), on.Click(s.ColChange), h.Text(label), h.Span(h.Class("sort-icon"), h.Text(icon)))
}
if col == "changePercent" {
return h.Th(h.If(isSorted, h.Class("sorted")), on.Click(s.ColChangePercent), h.Text(label), h.Span(h.Class("sort-icon"), h.Text(icon)))
}
return h.Th(h.If(isSorted, h.Class("sorted")), on.Click(s.ColVolume), h.Text(label), h.Span(h.Class("sort-icon"), h.Text(icon)))
}
func (s *StockGrid) View(ctx *via.CtxR) h.H {
stocks := s.GetPageStocks(ctx)
page := s.CurrentPage.Read(ctx)
totalCount := len(s.GetFilteredStocks(ctx))
totalPages := (totalCount + pageSize - 1) / pageSize
if totalPages < 1 {
totalPages = 1
}
pageNums := make([]int, totalPages)
for i := range pageNums {
pageNums[i] = i + 1
}
return h.Div(h.Class("stock-grid"),
h.StyleEl(h.Raw(`
.stock-grid {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', Roboto, sans-serif;
max-width: 1280px;
margin: 2rem auto;
padding: 0;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
overflow: hidden;
}
.stock-grid .grid-header {
padding: 1.75rem 2rem;
border-bottom: 1px solid #f0f0f0;
background: linear-gradient(135deg, #fafbff 0%, #f5f7ff 100%);
}
.stock-grid .grid-title {
font-size: 1.625rem;
font-weight: 700;
color: #1a1a2e;
margin-bottom: 0.375rem;
letter-spacing: -0.02em;
}
.stock-grid .grid-subtitle {
font-size: 0.875rem;
color: #6b7280;
}
.stock-grid .toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2rem;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
}
.stock-grid .search-box {
position: relative;
flex: 0 0 360px;
}
.stock-grid .search-box input {
width: 100%;
padding: 0.625rem 1rem;
border: 1.5px solid #e5e7eb;
border-radius: 10px;
font-size: 0.9375rem;
background: #ffffff;
transition: all 0.2s ease;
color: #1f2937;
}
.stock-grid .search-box input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.stock-grid .search-box input::placeholder {
color: #9ca3af;
}
.stock-grid .stats-bar {
display: flex;
gap: 1.5rem;
}
.stock-grid .stat-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: #6b7280;
}
.stock-grid .stat-value {
font-weight: 600;
color: #1f2937;
font-size: 0.9375rem;
}
.stock-grid .table-container {
overflow-x: auto;
}
.stock-grid table {
width: 100%;
border-collapse: collapse;
font-size: 0.9375rem;
}
.stock-grid thead th {
background: linear-gradient(180deg, #f8f9fc 0%, #f1f3f9 100%);
padding: 0.875rem 1.25rem;
text-align: left;
font-weight: 600;
color: #374151;
border-bottom: 2px solid #e5e7eb;
cursor: pointer;
user-select: none;
white-space: nowrap;
transition: background 0.15s ease;
position: relative;
}
.stock-grid thead th:hover {
background: linear-gradient(180deg, #eef0f7 0%, #e5e8f0 100%);
}
.stock-grid thead th.sorted {
color: #4f46e5;
background: linear-gradient(180deg, #eef2ff 0%, #e0e7ff 100%);
}
.stock-grid thead th.sorted::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -2px;
height: 2px;
background: #4f46e5;
}
.stock-grid .sort-icon {
display: inline-flex;
align-items: center;
margin-left: 0.375rem;
font-size: 0.6875rem;
opacity: 0.35;
transition: opacity 0.15s ease;
}
.stock-grid th.sorted .sort-icon {
opacity: 1;
color: #4f46e5;
}
.stock-grid tbody td {
padding: 0.875rem 1.25rem;
border-bottom: 1px solid #f3f4f6;
color: #1f2937;
transition: background 0.15s ease;
}
.stock-grid tbody tr:hover td {
background: #f9fafb;
}
.stock-grid tbody tr:last-child td {
border-bottom: none;
}
.stock-grid .symbol {
font-weight: 600;
color: #111827;
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
}
.stock-grid .name {
color: #4b5563;
}
.stock-grid .price {
text-align: right;
font-weight: 600;
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
color: #111827;
}
.stock-grid .change, .stock-grid .change-percent {
text-align: right;
font-weight: 500;
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
}
.stock-grid .volume {
text-align: right;
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
color: #6b7280;
}
.stock-grid .positive {
color: #dc2626;
}
.stock-grid .negative {
color: #16a34a;
}
.stock-grid .change-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 600;
}
.stock-grid .change-badge.positive {
background: #fef2f2;
color: #dc2626;
}
.stock-grid .change-badge.negative {
background: #f0fdf4;
color: #16a34a;
}
.stock-grid .pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2rem;
background: #fafafa;
border-top: 1px solid #f0f0f0;
}
.stock-grid .pagination-info {
color: #6b7280;
font-size: 0.875rem;
}
.stock-grid .pagination-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.stock-grid .pagination-controls button {
padding: 0.5rem 0.875rem;
border: 1.5px solid #e5e7eb;
background: #ffffff;
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
transition: all 0.15s ease;
}
.stock-grid .pagination-controls button:hover:not(:disabled) {
border-color: #667eea;
color: #4f46e5;
background: #f5f3ff;
}
.stock-grid .pagination-controls button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.stock-grid .page-numbers {
display: flex;
gap: 0.25rem;
}
.stock-grid .page-numbers button {
min-width: 2.25rem;
text-align: center;
padding: 0.5rem 0.25rem;
}
.stock-grid .page-numbers button.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff;
border-color: transparent;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
}
.stock-grid .page-numbers button.active:hover {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff;
}
`)),
h.Div(h.Class("grid-header"),
h.H1(h.Class("grid-title"), h.Text("股票行情数据")),
h.P(h.Class("grid-subtitle"), h.Text("实时行情 · 支持排序、筛选、分页")),
),
h.Div(h.Class("toolbar"),
h.Div(h.Class("search-box"),
h.Input(
h.Type("text"),
h.Placeholder("搜索股票代码或名称..."),
s.SearchText.Bind(),
on.Input(s.Search, on.Debounce("300ms")),
on.Key("Enter", s.Search),
),
),
h.Div(h.Class("stats-bar"),
h.Div(h.Class("stat-item"),
h.Span(h.Text("股票总数:")),
h.Span(h.Class("stat-value"), h.Textf("%d", totalCount)),
),
h.Div(h.Class("stat-item"),
h.Span(h.Text("当前页:")),
h.Span(h.Class("stat-value"), h.Textf("%d / %d", page, totalPages)),
),
),
),
h.Div(h.Class("table-container"),
h.Table(
h.THead(
h.Tr(
s.sortTh("symbol", "代码", ctx),
s.sortTh("name", "名称", ctx),
s.sortTh("price", "现价", ctx),
s.sortTh("change", "涨跌额", ctx),
s.sortTh("changePercent", "涨跌幅", ctx),
s.sortTh("volume", "成交量", ctx),
),
),
h.TBody(
h.Each(stocks, func(stock Stock) h.H {
changeClass := "positive"
changeSign := "+"
if stock.Change < 0 {
changeClass = "negative"
changeSign = ""
}
return h.Tr(
h.Td(h.Class("symbol"), h.Text(stock.Symbol)),
h.Td(h.Class("name"), h.Text(stock.Name)),
h.Td(h.Class("price"), h.Textf("$%.2f", stock.Price)),
h.Td(h.Class("change"),
h.Span(h.Class("change-badge "+changeClass),
h.Textf("%s%.2f", changeSign, stock.Change),
),
),
h.Td(h.Class("change-percent"),
h.Span(h.Class("change-badge "+changeClass),
h.Textf("%s%.2f%%", changeSign, stock.ChangePercent),
),
),
h.Td(h.Class("volume"), h.Textf("%d", stock.Volume)),
)
}),
),
),
),
h.Div(h.Class("pagination"),
h.Div(h.Class("pagination-info"),
h.Textf("显示第 %d - %d 条,共 %d 条记录",
minInt(totalCount, (page-1)*pageSize+1),
minInt(page*pageSize, totalCount),
totalCount,
),
),
h.Div(h.Class("pagination-controls"),
h.Button(
h.Text("上一页"),
on.Click(s.PrevPage),
h.If(page <= 1, h.Disabled()),
),
h.Div(h.Class("page-numbers"),
h.Each(pageNums, func(pageNum int) h.H {
return h.Button(
h.Textf("%d", pageNum),
on.Click(s.GoToPage, on.SetSignal(&s.TargetPage.Signal, pageNum)),
h.If(pageNum == page, h.Class("active")),
)
}),
),
h.Button(
h.Text("下一页"),
on.Click(s.NextPage),
h.If(page >= totalPages, h.Disabled()),
),
),
),
)
}
func main() {
port := ":8080"
app := via.New(
via.WithTitle("Stock Grid"),
)
via.Mount[StockGrid](app, "/")
app.HandleFunc("GET /_datastar.js.map", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"version":3,"sources":[],"names":[],"mappings":""}`))
})
log.Printf("Starting server on port %s...", port)
server := &http.Server{Addr: port, Handler: app}
lc := &net.ListenConfig{
Control: func(network, address string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1)
})
},
}
ln, err := lc.Listen(nil, "tcp", port)
if err != nil {
log.Fatalf("Port %s is already in use! Error: %v", port, err)
}
if err := server.Serve(ln); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}