12 Commits

Author SHA1 Message Date
c0f4782f2b fix: maplibre reactive signal bugs and stale signal re-push (#3)
Some checks failed
CI / Build and Test (push) Failing after 36s
2026-02-20 18:31:27 +00:00
47dcab8fea chore: make Gitea the primary remote, GitHub as mirror (#2)
Some checks failed
CI / Build and Test (push) Has been cancelled
2026-02-20 18:25:39 +00:00
Ryan Hamamura
e63ebd1401 ci: re-trigger workflow
Some checks failed
CI / Build and Test (push) Has been cancelled
2026-02-19 14:49:48 -10:00
Ryan Hamamura
b26ded951f ci: trigger initial workflow run
Some checks failed
CI / Build and Test (push) Has been cancelled
2026-02-19 14:45:19 -10:00
Ryan Hamamura
8bb1b99ae9 chore: add PR workflow and worktree-aware release process
Some checks failed
CI / Build and Test (push) Has been cancelled
2026-02-19 14:43:46 -10:00
Ryan Hamamura
0d8bf04446 chore: add worktree support for parallel Claude Code sessions
Some checks failed
CI / Build and Test (push) Has been cancelled
2026-02-19 13:57:06 -10:00
Ryan Hamamura
742212fd20 feat: add maplibre subpackage for type-safe MapLibre GL JS maps
Some checks failed
CI / Build and Test (push) Has been cancelled
Provides a Go API for interactive maps within Via applications:
- Plugin serves vendored MapLibre GL JS v4.7.1 assets
- Map struct with pre/post-render source, layer, marker, popup management
- Viewport signal sync (center, zoom, bearing, pitch) via hidden inputs
- FlyTo, SetCenter, SetZoom and other viewport setters via ExecScript
- Idempotent init script with SPA cleanup via MutationObserver
- Example app demonstrating markers, GeoJSON layers, and FlyTo actions
2026-02-19 13:37:16 -10:00
Ryan Hamamura
60009124c9 feat: add declarative Options.Streams for automatic JetStream stream creation
Some checks failed
CI / Build and Test (push) Has been cancelled
Streams listed in Options.Streams are created by Start() when the
embedded NATS server initializes, replacing manual EnsureStream calls
during setup. Migrates nats-chatroom and pubsub-crud examples.
2026-02-19 12:24:44 -10:00
Ryan Hamamura
42b21348cb fix: use random port for embedded NATS to avoid binding conflicts
Port 0 is treated as default (4222) by NATS server, causing hangs when
that port is unavailable. Port -1 (RANDOM_PORT) binds to an OS-assigned
free port, which is correct for an embedded server.
2026-02-19 12:24:37 -10:00
Ryan Hamamura
58ad9a2699 feat: add SSE keepalive and liveness tracking for resilient connections
Some checks failed
CI / Build and Test (push) Has been cancelled
Add 30s keepalive pings to prevent proxy/CDN idle timeouts from killing
SSE connections silently. Track lastSeenAt on each SSE connect attempt
so Datastar's retry signals keep contexts alive through the reaper.
2026-02-19 12:07:25 -10:00
Ryan Hamamura
f3a9c8036f refactor: use computed signals in pubsub-crud and chatroom examples
Some checks failed
CI / Build and Test (push) Has been cancelled
2026-02-19 09:03:13 -10:00
Ryan Hamamura
6763e1a420 feat: add computed signals for derived reactive values
All checks were successful
CI / Build and Test (push) Successful in 33s
Read-only signals whose value is a function of other signals,
recomputed automatically at sync time. Supports String, Int, Bool,
and Text methods. Components store computed signals on the parent
page context like regular signals.
2026-02-18 09:22:40 -10:00
28 changed files with 2031 additions and 140 deletions

14
.claude/commands/pr.md Normal file
View File

@@ -0,0 +1,14 @@
Create a PR on Gitea, wait for CI, and squash-merge it. Push code to both remotes.
1. If in a worktree (working directory contains `.claude/worktrees/`), you are already on a feature branch — do NOT create a new one. Otherwise, create a new branch from main with a descriptive name.
2. Stage and commit all changes with a clean, semantic commit message. No Claude attribution lines.
3. Fetch latest main and rebase: `git fetch gitea main && git rebase gitea/main`.
- If conflicts occur, abort the rebase (`git rebase --abort`), analyze the conflicting files, write a plan to resolve them, and present the plan to the user before proceeding.
4. Push the branch to both remotes: `git push -u gitea <branch> && git push origin <branch>` (use `--force-with-lease` if already pushed).
5. Create a Gitea PR: `tea pr create --head <branch> --base main`. Reference related issues with `#X`. Only use `Closes #X` if the PR fully resolves the issue.
6. Wait for CI to pass: poll Gitea CI status. If CI fails, report the failure and stop — do not merge.
7. Once CI passes, squash-merge on Gitea: `tea pr merge <index> --style squash` with a clean, semantic commit message including the PR number. No Claude attribution lines.
8. Update local main and push to both remotes: `git checkout main && git pull gitea main && git push origin main`.
9. Clean up remote branches: `git push gitea --delete <branch> && git push origin --delete <branch>`.
10. Prune refs: `git remote prune gitea && git remote prune origin`.
11. Report the merged PR URL.

View File

@@ -1,14 +1,20 @@
Create a new release for this project. Steps: Create a new release on Gitea. Push tags to both remotes.
1. Fetch tags from all remotes so the version list is current. ## Pre-flight
2. Check for uncommitted changes. If any exist, commit them with a clean semantic commit message. No Claude attribution lines.
3. Review the commits since the last tag. Based on their content, recommend a semver bump: 1. **Worktree guard**: If the working directory is inside `.claude/worktrees/`, STOP and tell the user: "Releases must be created from a non-worktree session on main. Exit this worktree or start a new session, then run /release." Do not proceed.
2. Verify you are on `main`. If not, STOP.
3. Verify there are no uncommitted changes. If there are, STOP — they should go through a PR.
4. Run `git pull --ff-only` on main. Fetch tags from all remotes.
## Release
5. Review commits since the last tag. Recommend a semver bump:
- **major**: breaking/incompatible API changes - **major**: breaking/incompatible API changes
- **minor**: new features, meaningful new behavior - **minor**: new features, meaningful new behavior
- **patch**: bug fixes, docs, refactoring with no new features - **patch**: bug fixes, docs, refactoring with no new features
Present the proposed version, the bump rationale, and the commit list. Wait for user approval before continuing. Present the proposed version, bump rationale, and commit list. Wait for user approval.
4. Tag the new version and push the tag + commits to all remotes (origin, gitea, etc.). 6. Tag the new version. Push the tag to both remotes: `git push gitea <tag> && git push origin <tag>`.
5. Generate release notes from the commits since the last tag, grouped by type (features, fixes, docs/refactoring). 7. Generate release notes grouped by type (features, fixes, chores).
6. Create a GitHub release using `gh release create`. 8. Create a Gitea release with `tea releases create` using the notes.
7. Create a Gitea release using `tea releases create` with the same notes. 9. Report the release URL and confirm all remotes are up to date.
8. Report both release URLs and confirm all remotes are up to date.

View File

@@ -8,9 +8,6 @@ on:
branches: branches:
- '**' - '**'
permissions:
contents: read
jobs: jobs:
build-test: build-test:
name: Build and Test name: Build and Test

3
.gitignore vendored
View File

@@ -52,3 +52,6 @@ internal/examples/nats-chatroom/nats-chatroom
# NATS data directory # NATS data directory
data/ data/
# Claude Code worktrees
.claude/worktrees/

20
CLAUDE.md Normal file
View File

@@ -0,0 +1,20 @@
# Via Project Instructions
## Workflow
All changes go through PRs:
1. Enter a worktree (`EnterWorktree`) at session start.
2. Make changes, commit with semantic messages.
3. `/pr` to push, open a PR, wait for CI, and squash-merge.
## Releasing
Run `/release` from a **non-worktree session on main**. It tags and publishes
what is already on main — it does not commit new changes.
## Worktree Usage
Always enter a worktree at the start of a session using the `EnterWorktree`
tool. This prevents parallel Claude Code sessions from interfering with each
other.

View File

@@ -97,7 +97,7 @@ func buildAttrKey(event string, opts *triggerOpts) string {
} }
// WithSignal sets a signal value before triggering the action. // WithSignal sets a signal value before triggering the action.
func WithSignal(sig *signal, value string) ActionTriggerOption { func WithSignal(sig *Signal, value string) ActionTriggerOption {
return withSignalOpt{ return withSignalOpt{
signalID: sig.ID(), signalID: sig.ID(),
value: fmt.Sprintf("'%s'", value), value: fmt.Sprintf("'%s'", value),
@@ -105,7 +105,7 @@ func WithSignal(sig *signal, value string) ActionTriggerOption {
} }
// WithSignalInt sets a signal to an int value before triggering the action. // WithSignalInt sets a signal to an int value before triggering the action.
func WithSignalInt(sig *signal, value int) ActionTriggerOption { func WithSignalInt(sig *Signal, value int) ActionTriggerOption {
return withSignalOpt{ return withSignalOpt{
signalID: sig.ID(), signalID: sig.ID(),
value: strconv.Itoa(value), value: strconv.Itoa(value),

55
computed.go Normal file
View File

@@ -0,0 +1,55 @@
package via
import (
"fmt"
"strconv"
"strings"
"github.com/ryanhamamura/via/h"
)
// computedSignal is a read-only signal whose value is derived from other signals.
// It recomputes on every read and is included in patches only when the value changes.
type computedSignal struct {
id string
compute func() string
lastVal string
changed bool
}
func (s *computedSignal) ID() string {
return s.id
}
func (s *computedSignal) String() string {
return s.compute()
}
func (s *computedSignal) Int() int {
if n, err := strconv.Atoi(s.String()); err == nil {
return n
}
return 0
}
func (s *computedSignal) Bool() bool {
val := strings.ToLower(s.String())
return val == "true" || val == "1" || val == "yes" || val == "on"
}
func (s *computedSignal) Text() h.H {
return h.Span(h.Data("text", "$"+s.id))
}
// recompute calls the compute function and sets changed if the value differs from lastVal.
func (s *computedSignal) recompute() {
val := s.compute()
if val != s.lastVal {
s.lastVal = val
s.changed = true
}
}
func (s *computedSignal) patchValue() string {
return fmt.Sprintf("%v", s.lastVal)
}

190
computed_test.go Normal file
View File

@@ -0,0 +1,190 @@
package via
import (
"bytes"
"fmt"
"testing"
"github.com/ryanhamamura/via/h"
"github.com/stretchr/testify/assert"
)
func TestComputedBasic(t *testing.T) {
v := New()
var cs *computedSignal
v.Page("/", func(c *Context) {
sig1 := c.Signal("hello")
sig2 := c.Signal("world")
cs = c.Computed(func() string {
return sig1.String() + " " + sig2.String()
})
c.View(func() h.H { return h.Div() })
})
assert.Equal(t, "hello world", cs.String())
}
func TestComputedReactivity(t *testing.T) {
v := New()
var cs *computedSignal
var sig1 *Signal
v.Page("/", func(c *Context) {
sig1 = c.Signal("a")
sig2 := c.Signal("b")
cs = c.Computed(func() string {
return sig1.String() + sig2.String()
})
c.View(func() h.H { return h.Div() })
})
assert.Equal(t, "ab", cs.String())
sig1.SetValue("x")
assert.Equal(t, "xb", cs.String())
}
func TestComputedInt(t *testing.T) {
v := New()
var cs *computedSignal
v.Page("/", func(c *Context) {
sig := c.Signal(21)
cs = c.Computed(func() string {
return fmt.Sprintf("%d", sig.Int()*2)
})
c.View(func() h.H { return h.Div() })
})
assert.Equal(t, 42, cs.Int())
}
func TestComputedBool(t *testing.T) {
v := New()
var cs *computedSignal
v.Page("/", func(c *Context) {
sig := c.Signal("true")
cs = c.Computed(func() string {
return sig.String()
})
c.View(func() h.H { return h.Div() })
})
assert.True(t, cs.Bool())
}
func TestComputedText(t *testing.T) {
v := New()
var cs *computedSignal
v.Page("/", func(c *Context) {
cs = c.Computed(func() string { return "hi" })
c.View(func() h.H { return h.Div() })
})
var buf bytes.Buffer
err := cs.Text().Render(&buf)
assert.NoError(t, err)
assert.Contains(t, buf.String(), `data-text="$`+cs.ID()+`"`)
}
func TestComputedChangeDetection(t *testing.T) {
v := New()
var ctx *Context
var sig *Signal
v.Page("/", func(c *Context) {
ctx = c
sig = c.Signal("a")
c.Computed(func() string {
return sig.String() + "!"
})
c.View(func() h.H { return h.Div() })
})
// First patch includes computed (changed=true from init)
patch1 := ctx.prepareSignalsForPatch()
assert.NotEmpty(t, patch1)
// Second patch: nothing changed, computed should not be included
patch2 := ctx.prepareSignalsForPatch()
// Regular signal still has changed=true (not reset in prepareSignalsForPatch),
// but computed should not appear since its value didn't change.
hasComputed := false
ctx.signals.Range(func(_, value any) bool {
if cs, ok := value.(*computedSignal); ok {
_, inPatch := patch2[cs.ID()]
hasComputed = inPatch
}
return true
})
assert.False(t, hasComputed)
// After changing dependency, computed should reappear
sig.SetValue("b")
patch3 := ctx.prepareSignalsForPatch()
found := false
ctx.signals.Range(func(_, value any) bool {
if cs, ok := value.(*computedSignal); ok {
if v, ok := patch3[cs.ID()]; ok {
assert.Equal(t, "b!", v)
found = true
}
}
return true
})
assert.True(t, found)
}
func TestComputedInComponent(t *testing.T) {
v := New()
var cs *computedSignal
var parentCtx *Context
v.Page("/", func(c *Context) {
parentCtx = c
c.Component(func(comp *Context) {
sig := comp.Signal("via")
cs = comp.Computed(func() string {
return "hello " + sig.String()
})
comp.View(func() h.H { return h.Div() })
})
c.View(func() h.H { return h.Div() })
})
assert.Equal(t, "hello via", cs.String())
// Verify it's stored on the parent page context
found := false
parentCtx.signals.Range(func(_, value any) bool {
if stored, ok := value.(*computedSignal); ok && stored.ID() == cs.ID() {
found = true
}
return true
})
assert.True(t, found)
}
func TestComputedIsReadOnly(t *testing.T) {
// Compile-time guarantee: *computedSignal has no Bind() or SetValue() methods.
// This test exists as documentation — if someone adds those methods, the
// interface assertion below will need updating and serve as a reminder.
var cs interface{} = &computedSignal{}
type writable interface {
SetValue(any)
}
type bindable interface {
Bind() h.H
}
_, isWritable := cs.(writable)
_, isBindable := cs.(bindable)
assert.False(t, isWritable, "computedSignal must not have SetValue")
assert.False(t, isBindable, "computedSignal must not have Bind")
}
func TestComputedInjectSignalsSkips(t *testing.T) {
v := New()
var ctx *Context
var cs *computedSignal
v.Page("/", func(c *Context) {
ctx = c
cs = c.Computed(func() string { return "fixed" })
c.View(func() h.H { return h.Div() })
})
// Simulate browser sending back a value for the computed signal — should be ignored
ctx.injectSignals(map[string]any{
cs.ID(): "injected",
})
assert.Equal(t, "fixed", cs.String())
}

View File

@@ -53,10 +53,15 @@ type Options struct {
// Defaults to "/_datastar.js" if empty. // Defaults to "/_datastar.js" if empty.
DatastarPath string DatastarPath string
// PubSub enables publish/subscribe messaging. Use vianats.New() for an // PubSub enables publish/subscribe messaging. When nil, an embedded NATS
// embedded NATS backend, or supply any PubSub implementation. // server starts automatically in Start(). Supply any PubSub implementation
// to replace it.
PubSub PubSub PubSub PubSub
// Streams declares JetStream streams to create when Start() initializes
// the embedded NATS server. Ignored when a custom PubSub is configured.
Streams []StreamConfig
// ContextSuspendAfter is the time a context may be disconnected before // ContextSuspendAfter is the time a context may be disconnected before
// the reaper suspends it (frees page resources but keeps the context // the reaper suspends it (frees page resources but keeps the context
// shell for seamless re-init on reconnect). Default: 15m. // shell for seamless re-init on reconnect). Default: 15m.

View File

@@ -42,6 +42,7 @@ type Context struct {
createdAt time.Time createdAt time.Time
sseConnected atomic.Bool sseConnected atomic.Bool
sseDisconnectedAt atomic.Pointer[time.Time] sseDisconnectedAt atomic.Pointer[time.Time]
lastSeenAt atomic.Pointer[time.Time]
suspended atomic.Bool suspended atomic.Bool
} }
@@ -174,11 +175,11 @@ func (c *Context) OnInterval(duration time.Duration, handler func()) func() {
// the Context before each action call. // the Context before each action call.
// If any signal value is updated by the server, the update is automatically sent to the // If any signal value is updated by the server, the update is automatically sent to the
// browser when using Sync() or SyncSignsls(). // browser when using Sync() or SyncSignsls().
func (c *Context) Signal(v any) *signal { func (c *Context) Signal(v any) *Signal {
sigID := genRandID() sigID := genRandID()
if v == nil { if v == nil {
c.app.logErr(c, "failed to bind signal: nil signal value") c.app.logErr(c, "failed to bind signal: nil signal value")
return &signal{ return &Signal{
id: sigID, id: sigID,
val: "error", val: "error",
err: fmt.Errorf("context '%s' failed to bind signal '%s': nil signal value", c.id, sigID), err: fmt.Errorf("context '%s' failed to bind signal '%s': nil signal value", c.id, sigID),
@@ -190,7 +191,7 @@ func (c *Context) Signal(v any) *signal {
v = string(j) v = string(j)
} }
} }
sig := &signal{ sig := &Signal{
id: sigID, id: sigID,
val: v, val: v,
changed: true, changed: true,
@@ -207,6 +208,40 @@ func (c *Context) Signal(v any) *signal {
} }
// Computed creates a read-only signal whose value is derived from the given function.
// The function is called on every read (String/Int/Bool) for fresh values,
// and during sync to detect changes for browser patches.
//
// Computed signals cannot be bound to inputs or set manually.
//
// Example:
//
// full := c.Computed(func() string {
// return first.String() + " " + last.String()
// })
// c.View(func() h.H {
// return h.Span(full.Text())
// })
func (c *Context) Computed(fn func() string) *computedSignal {
sigID := genRandID()
initial := fn()
cs := &computedSignal{
id: sigID,
compute: fn,
lastVal: initial,
changed: true,
}
c.mu.Lock()
defer c.mu.Unlock()
if c.isComponent() {
c.parentPageCtx.signals.Store(sigID, cs)
} else {
c.signals.Store(sigID, cs)
}
return cs
}
func (c *Context) injectSignals(sigs map[string]any) { func (c *Context) injectSignals(sigs map[string]any) {
if sigs == nil { if sigs == nil {
c.app.logErr(c, "signal injection failed: nil signals") c.app.logErr(c, "signal injection failed: nil signals")
@@ -219,13 +254,13 @@ func (c *Context) injectSignals(sigs map[string]any) {
for sigID, val := range sigs { for sigID, val := range sigs {
item, ok := c.signals.Load(sigID) item, ok := c.signals.Load(sigID)
if !ok { if !ok {
c.signals.Store(sigID, &signal{ c.signals.Store(sigID, &Signal{
id: sigID, id: sigID,
val: val, val: val,
}) })
continue continue
} }
if sig, ok := item.(*signal); ok { if sig, ok := item.(*Signal); ok {
sig.val = val sig.val = val
sig.changed = false sig.changed = false
} }
@@ -248,13 +283,21 @@ func (c *Context) prepareSignalsForPatch() map[string]any {
defer c.mu.RUnlock() defer c.mu.RUnlock()
updatedSigs := make(map[string]any) updatedSigs := make(map[string]any)
c.signals.Range(func(sigID, value any) bool { c.signals.Range(func(sigID, value any) bool {
if sig, ok := value.(*signal); ok { switch sig := value.(type) {
case *Signal:
if sig.err != nil { if sig.err != nil {
c.app.logWarn(c, "signal '%s' is out of sync: %v", sig.id, sig.err) c.app.logWarn(c, "signal '%s' is out of sync: %v", sig.id, sig.err)
return true return true
} }
if sig.changed { if sig.changed {
updatedSigs[sigID.(string)] = fmt.Sprintf("%v", sig.val) updatedSigs[sigID.(string)] = fmt.Sprintf("%v", sig.val)
sig.changed = false
}
case *computedSignal:
sig.recompute()
if sig.changed {
updatedSigs[sigID.(string)] = sig.patchValue()
sig.changed = false
} }
} }
return true return true
@@ -552,7 +595,7 @@ func (c *Context) unsubscribeAll() {
// can operate on all fields by default. // can operate on all fields by default.
func (c *Context) Field(initial any, rules ...Rule) *Field { func (c *Context) Field(initial any, rules ...Rule) *Field {
f := &Field{ f := &Field{
signal: c.Signal(initial), Signal: c.Signal(initial),
rules: rules, rules: rules,
initialVal: initial, initialVal: initial,
} }

View File

@@ -4,7 +4,7 @@ Infrastructure for multi-user real-time communication and persistent state.
## PubSub ## PubSub
Via includes an embedded NATS server that starts automatically with `via.New()`. No external services required — pub/sub works out of the box. Via includes an embedded NATS server that starts automatically with `v.Start()`. No external services required — pub/sub works out of the box.
### Interface ### Interface
@@ -73,14 +73,18 @@ This disables the embedded NATS server. The `NATSConn()` and `JetStream()` acces
NATS JetStream provides persistent, replayable message streams. Useful for chat history, event logs, or any scenario where new subscribers need to catch up on past messages. NATS JetStream provides persistent, replayable message streams. Useful for chat history, event logs, or any scenario where new subscribers need to catch up on past messages.
### Ensure a stream exists ### Declaring streams
The recommended approach is to declare streams in `Options.Streams`. They are created automatically when `v.Start()` initializes the embedded NATS server:
```go ```go
err := via.EnsureStream(v, via.StreamConfig{ v.Config(via.Options{
Name: "CHAT", Streams: []via.StreamConfig{{
Subjects: []string{"chat.>"}, Name: "CHAT",
MaxMsgs: 1000, Subjects: []string{"chat.>"},
MaxAge: 24 * time.Hour, MaxMsgs: 1000,
MaxAge: 24 * time.Hour,
}},
}) })
``` ```
@@ -91,7 +95,16 @@ err := via.EnsureStream(v, via.StreamConfig{
| `MaxMsgs` | Maximum number of messages to retain | | `MaxMsgs` | Maximum number of messages to retain |
| `MaxAge` | Maximum age before messages are discarded | | `MaxAge` | Maximum age before messages are discarded |
Call `EnsureStream` during app initialization, before `v.Start()`. For dynamic stream creation after startup, `EnsureStream` is also available:
```go
err := via.EnsureStream(v, via.StreamConfig{
Name: "EVENTS",
Subjects: []string{"events.>"},
MaxMsgs: 500,
MaxAge: 12 * time.Hour,
})
```
### Replay history ### Replay history

View File

@@ -1,10 +1,10 @@
package via package via
// Field is a signal with built-in validation rules and error state. // Field is a signal with built-in validation rules and error state.
// It embeds *signal, so all signal methods (Bind, String, Int, Bool, SetValue, Text, ID) // It embeds *Signal, so all signal methods (Bind, String, Int, Bool, SetValue, Text, ID)
// work transparently. // work transparently.
type Field struct { type Field struct {
*signal *Signal
rules []Rule rules []Rule
errors []string errors []string
initialVal any initialVal any

View File

@@ -0,0 +1,52 @@
// Spike to validate that Datastar's data-effect re-evaluates when signals are
// updated via PatchSignals from the server, and that Via's hex signal IDs work
// in $signalID expression syntax.
package main
import (
"fmt"
"math/rand"
"time"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
func main() {
v := via.New()
v.Config(via.Options{
DocumentTitle: "data-effect Spike",
ServerAddress: ":7332",
DevMode: true,
})
v.Page("/", func(c *via.Context) {
x := c.Signal(0)
y := c.Signal(0)
c.OnInterval(time.Second, func() {
x.SetValue(rand.Intn(500))
y.SetValue(rand.Intn(500))
c.SyncSignals()
})
c.View(func() h.H {
return h.Div(
h.Attr("style", "padding:1rem;font-family:sans-serif"),
h.H1(h.Text("data-effect Spike")),
h.P(h.Text("x: "), x.Text(), h.Text(" y: "), y.Text()),
h.Div(
h.ID("box"),
h.Attr("style", "width:20px;height:20px;background:red;position:absolute"),
h.DataEffect(fmt.Sprintf(
"document.getElementById('box').style.left=$%s+'px';"+
"document.getElementById('box').style.top=$%s+'px'",
x.ID(), y.ID(),
)),
),
)
})
})
v.Start()
}

View File

@@ -0,0 +1,154 @@
package main
import (
"math/rand"
"time"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
"github.com/ryanhamamura/via/maplibre"
)
func main() {
v := via.New()
v.Config(via.Options{
DocumentTitle: "MapLibre GL Example",
ServerAddress: ":7331",
DevMode: true,
Plugins: []via.Plugin{maplibre.Plugin},
})
v.Page("/", func(c *via.Context) {
m := maplibre.New(c, maplibre.Options{
Style: "https://demotiles.maplibre.org/style.json",
Center: maplibre.LngLat{Lng: -122.4194, Lat: 37.7749},
Zoom: 10,
Height: "500px",
})
m.AddControl("nav", maplibre.NavigationControl{})
m.AddControl("scale", maplibre.ScaleControl{Unit: "metric"})
// Static markers with popups
m.AddMarker("sf", maplibre.Marker{
LngLat: maplibre.LngLat{Lng: -122.4194, Lat: 37.7749},
Color: "#e74c3c",
Popup: &maplibre.Popup{
Content: "<strong>San Francisco</strong><p>The Golden City</p>",
},
})
m.AddMarker("oak", maplibre.Marker{
LngLat: maplibre.LngLat{Lng: -122.2711, Lat: 37.8044},
Color: "#2ecc71",
Popup: &maplibre.Popup{
Content: "<strong>Oakland</strong>",
},
})
// Signal-backed marker — server pushes position updates
vehicleLng := c.Signal(-122.43)
vehicleLat := c.Signal(37.77)
m.AddMarker("vehicle", maplibre.Marker{
LngSignal: vehicleLng,
LatSignal: vehicleLat,
Color: "#9b59b6",
})
c.OnInterval(time.Second, func() {
vehicleLng.SetValue(-122.43 + (rand.Float64()-0.5)*0.02)
vehicleLat.SetValue(37.77 + (rand.Float64()-0.5)*0.02)
c.SyncSignals()
})
// Draggable marker — user drags, signals update
pinLng := c.Signal(-122.41)
pinLat := c.Signal(37.78)
m.AddMarker("pin", maplibre.Marker{
LngSignal: pinLng,
LatSignal: pinLat,
Color: "#3498db",
Draggable: true,
})
// Click event — click to place a marker
click := m.OnClick()
handleClick := c.Action(func() {
e := click.Data()
m.AddMarker("clicked", maplibre.Marker{
LngLat: e.LngLat,
Color: "#f39c12",
})
})
// GeoJSON polygon source + fill layer
m.AddSource("park", maplibre.GeoJSONSource{
Data: map[string]any{
"type": "Feature",
"geometry": map[string]any{
"type": "Polygon",
"coordinates": []any{[]any{
[]float64{-122.4547, 37.7654},
[]float64{-122.4547, 37.7754},
[]float64{-122.4387, 37.7754},
[]float64{-122.4387, 37.7654},
[]float64{-122.4547, 37.7654},
}},
},
"properties": map[string]any{
"name": "Golden Gate Park",
},
},
})
m.AddLayer(maplibre.Layer{
ID: "park-fill",
Type: "fill",
Source: "park",
Paint: map[string]any{
"fill-color": "#2ecc71",
"fill-opacity": 0.3,
},
})
// FlyTo actions using CameraOptions
zoom14 := 14.0
flyToSF := c.Action(func() {
m.FlyTo(maplibre.CameraOptions{
Center: &maplibre.LngLat{Lng: -122.4194, Lat: 37.7749},
Zoom: &zoom14,
})
})
flyToOak := c.Action(func() {
m.FlyTo(maplibre.CameraOptions{
Center: &maplibre.LngLat{Lng: -122.2711, Lat: 37.8044},
Zoom: &zoom14,
})
})
c.View(func() h.H {
return h.Div(
h.Div(
h.Attr("style", "max-width:960px;margin:0 auto;padding:1rem;font-family:sans-serif"),
h.H1(h.Text("MapLibre GL Example")),
m.Element(
click.Input(handleClick.OnInput()),
),
h.Div(h.Attr("style", "margin-top:1rem;display:flex;gap:0.5rem;flex-wrap:wrap"),
h.Button(h.Text("Fly to San Francisco"), flyToSF.OnClick()),
h.Button(h.Text("Fly to Oakland"), flyToOak.OnClick()),
),
h.Div(h.Attr("style", "margin-top:0.5rem;font-size:0.9rem"),
h.P(h.Text("Zoom: "), m.Zoom.Text()),
h.P(h.Text("Center: "), m.CenterLng.Text(), h.Text(", "), m.CenterLat.Text()),
h.P(h.Text("Vehicle: "), vehicleLng.Text(), h.Text(", "), vehicleLat.Text()),
h.P(h.Text("Draggable Pin: "), pinLng.Text(), h.Text(", "), pinLat.Text()),
),
),
)
})
})
v.Start()
}

View File

@@ -2,7 +2,7 @@
A chatroom built with Via and an **embedded NATS server**, demonstrating pub/sub messaging as an alternative to the custom `Rooms` implementation in `../chatroom`. A chatroom built with Via and an **embedded NATS server**, demonstrating pub/sub messaging as an alternative to the custom `Rooms` implementation in `../chatroom`.
Uses `delaneyj/toolbelt/embeddednats` to run NATS inside the same binary - no external server required. Via includes an embedded NATS server that starts automatically no external server required.
## Key Differences from Original Chatroom ## Key Differences from Original Chatroom
@@ -25,21 +25,6 @@ That's it. No separate NATS server needed.
Open multiple browser tabs at http://localhost:7331 to see messages broadcast across all clients. Open multiple browser tabs at http://localhost:7331 to see messages broadcast across all clients.
## How Embedded NATS Works
```go
// Start embedded NATS server (JetStream enabled by default)
ns, err := embeddednats.New(ctx,
embeddednats.WithDirectory("./data/nats"),
)
ns.WaitForServer()
// Get client connection to embedded server
nc, err := ns.Client()
```
Data is persisted to `./data/nats/` for JetStream durability.
## Architecture ## Architecture
``` ```
@@ -65,14 +50,16 @@ Data is persisted to `./data/nats/` for JetStream durability.
## JetStream Durability ## JetStream Durability
Messages persist to disk via JetStream: Messages persist to disk via JetStream. Streams are declared in `Options.Streams` and created automatically when `v.Start()` initializes the embedded NATS server:
```go ```go
js.AddStream(&nats.StreamConfig{ v.Config(via.Options{
Name: "CHAT", Streams: []via.StreamConfig{{
Subjects: []string{"chat.>"}, Name: "CHAT",
MaxMsgs: 1000, // Keep last 1000 messages Subjects: []string{"chat.>"},
MaxAge: 24 * time.Hour, MaxMsgs: 1000,
MaxAge: 24 * time.Hour,
}},
}) })
``` ```
@@ -87,23 +74,6 @@ Stop and restart the app - chat history survives.
- Manual join/leave channels - Manual join/leave channels
**This example - ~60 lines of NATS integration:** **This example - ~60 lines of NATS integration:**
- `embeddednats.New()` starts the server - `via.Subscribe(c, subject, handler)` for receiving
- `nc.Subscribe(subject, handler)` for receiving - `via.Publish(c, subject, data)` for sending
- `nc.Publish(subject, data)` for sending - Streams declared in `Options` — NATS handles delivery, no polling
- NATS handles delivery, no polling
## Next Steps
If this pattern proves useful, it could be promoted to a Via plugin:
```go
// Hypothetical future API
v.Config(via.WithEmbeddedNATS("./data/nats"))
// In page init
c.Subscribe("events.user.*", func(data []byte) {
c.Sync()
})
c.Publish("events.user.login", userData)
```

View File

@@ -21,18 +21,14 @@ func main() {
DocumentTitle: "NATS Chat", DocumentTitle: "NATS Chat",
LogLevel: via.LogLevelInfo, LogLevel: via.LogLevelInfo,
ServerAddress: ":7331", ServerAddress: ":7331",
Streams: []via.StreamConfig{{
Name: "CHAT",
Subjects: []string{"chat.>"},
MaxMsgs: 1000,
MaxAge: 24 * time.Hour,
}},
}) })
err := via.EnsureStream(v, via.StreamConfig{
Name: "CHAT",
Subjects: []string{"chat.>"},
MaxMsgs: 1000,
MaxAge: 24 * time.Hour,
})
if err != nil {
log.Fatalf("Failed to ensure stream: %v", err)
}
v.AppendToHead( v.AppendToHead(
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")), h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")),
h.StyleEl(h.Raw(chatCSS)), h.StyleEl(h.Raw(chatCSS)),
@@ -76,6 +72,6 @@ func main() {
protected := v.Group("", requireProfile) protected := v.Group("", requireProfile)
protected.Page("/", ChatPage) protected.Page("/", ChatPage)
log.Println("Starting NATS chatroom on :7331 (embedded NATS server)") log.Println("Starting NATS chatroom on :7331")
v.Start() v.Start()
} }

View File

@@ -18,6 +18,12 @@ func ProfilePage(c *via.Context) {
via.MaxLen(20, "Must be at most 20 characters"), via.MaxLen(20, "Must be at most 20 characters"),
) )
selectedEmoji := c.Signal(existingEmoji) selectedEmoji := c.Signal(existingEmoji)
previewName := c.Computed(func() string {
if name := nameField.String(); name != "" {
return name
}
return "Your Name"
})
saveToSession := func() bool { saveToSession := func() bool {
if !c.ValidateAll() { if !c.ValidateAll() {
@@ -68,18 +74,13 @@ func ProfilePage(c *via.Context) {
h.Button(h.Text("Start Chatting"), saveAndChat.OnClick()), h.Button(h.Text("Start Chatting"), saveAndChat.OnClick()),
) )
previewName := nameField.String()
if previewName == "" {
previewName = "Your Name"
}
return h.Div(h.Class("profile-page"), return h.Div(h.Class("profile-page"),
h.H2(h.Text("Your Profile"), h.DataViewTransition("page-title")), h.H2(h.Text("Your Profile"), h.DataViewTransition("page-title")),
// Live preview // Live preview
h.Div(h.Class("profile-preview"), h.Div(h.Class("profile-preview"),
h.Div(h.Class("avatar avatar-lg"), h.Text(selectedEmoji.String())), h.Div(h.Class("avatar avatar-lg"), h.Text(selectedEmoji.String())),
h.Span(h.Class("preview-name"), h.Text(previewName)), h.Span(h.Class("preview-name"), previewName.Text()),
), ),
h.Div(h.Class("profile-form"), h.Div(h.Class("profile-form"),

View File

@@ -53,18 +53,14 @@ func main() {
DocumentTitle: "Bookmarks", DocumentTitle: "Bookmarks",
LogLevel: via.LogLevelInfo, LogLevel: via.LogLevelInfo,
ServerAddress: ":7331", ServerAddress: ":7331",
Streams: []via.StreamConfig{{
Name: "BOOKMARKS",
Subjects: []string{"bookmarks.>"},
MaxMsgs: 1000,
MaxAge: 24 * time.Hour,
}},
}) })
err := via.EnsureStream(v, via.StreamConfig{
Name: "BOOKMARKS",
Subjects: []string{"bookmarks.>"},
MaxMsgs: 1000,
MaxAge: 24 * time.Hour,
})
if err != nil {
log.Fatalf("Failed to ensure stream: %v", err)
}
v.AppendToHead( v.AppendToHead(
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css")), h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css")),
h.Script(h.Src("https://cdn.tailwindcss.com")), h.Script(h.Src("https://cdn.tailwindcss.com")),
@@ -76,6 +72,12 @@ func main() {
titleSignal := c.Signal("") titleSignal := c.Signal("")
urlSignal := c.Signal("") urlSignal := c.Signal("")
targetIDSignal := c.Signal("") targetIDSignal := c.Signal("")
saveLabel := c.Computed(func() string {
if targetIDSignal.String() != "" {
return "Update Bookmark"
}
return "Add Bookmark"
})
via.Subscribe(c, "bookmarks.events", func(evt CRUDEvent) { via.Subscribe(c, "bookmarks.events", func(evt CRUDEvent) {
if evt.UserID == userID { if evt.UserID == userID {
@@ -205,11 +207,6 @@ func main() {
} }
bookmarksMu.RUnlock() bookmarksMu.RUnlock()
saveLabel := "Add Bookmark"
if isEditing {
saveLabel = "Update Bookmark"
}
return h.Div(h.Class("min-h-screen bg-base-200"), return h.Div(h.Class("min-h-screen bg-base-200"),
// Navbar // Navbar
h.Div(h.Class("navbar bg-base-100 shadow-sm"), h.Div(h.Class("navbar bg-base-100 shadow-sm"),
@@ -225,7 +222,7 @@ func main() {
// Form card // Form card
h.Div(h.Class("card bg-base-100 shadow"), h.Div(h.Class("card bg-base-100 shadow"),
h.Div(h.Class("card-body"), h.Div(h.Class("card-body"),
h.H2(h.Class("card-title"), h.Text(saveLabel)), h.H2(h.Class("card-title"), saveLabel.Text()),
h.Div(h.Class("flex flex-col gap-2"), h.Div(h.Class("flex flex-col gap-2"),
h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("Title"), titleSignal.Bind()), h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("Title"), titleSignal.Bind()),
h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("https://example.com"), urlSignal.Bind()), h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("https://example.com"), urlSignal.Bind()),
@@ -233,7 +230,7 @@ func main() {
h.If(isEditing, h.If(isEditing,
h.Button(h.Class("btn btn-ghost"), h.Text("Cancel"), cancelEdit.OnClick()), h.Button(h.Class("btn btn-ghost"), h.Text("Cancel"), cancelEdit.OnClick()),
), ),
h.Button(h.Class("btn btn-primary"), h.Text(saveLabel), save.OnClick()), h.Button(h.Class("btn btn-primary"), saveLabel.Text(), save.OnClick()),
), ),
), ),
), ),

418
maplibre/js.go Normal file
View File

@@ -0,0 +1,418 @@
package maplibre
import (
"encoding/json"
"fmt"
"strings"
)
// guard wraps JS code so it only runs when the map instance exists.
// The body can reference the map as `m`.
func guard(mapID, body string) string {
return fmt.Sprintf(
`(function(){var m=window.__via_maps&&window.__via_maps[%s];if(!m)return;%s})()`,
jsonStr(mapID), body,
)
}
// jsonStr JSON-encodes a string for safe embedding in JS.
func jsonStr(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
// jsonVal JSON-encodes an arbitrary value for safe embedding in JS.
func jsonVal(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
// initScript generates the idempotent map initialization JS.
func initScript(m *Map) string {
var b strings.Builder
b.WriteString(fmt.Sprintf(
`(function(){if(window.__via_maps&&window.__via_maps[%[1]s])return;`,
jsonStr(m.id),
))
// Build constructor options object
b.WriteString(fmt.Sprintf(
`var opts={container:%s,style:%s,center:[%s,%s],zoom:%s`,
jsonStr("_vmap_"+m.id),
jsonStr(m.opts.Style),
formatFloat(m.opts.Center.Lng),
formatFloat(m.opts.Center.Lat),
formatFloat(m.opts.Zoom),
))
if m.opts.Bearing != 0 {
b.WriteString(fmt.Sprintf(`,bearing:%s`, formatFloat(m.opts.Bearing)))
}
if m.opts.Pitch != 0 {
b.WriteString(fmt.Sprintf(`,pitch:%s`, formatFloat(m.opts.Pitch)))
}
if m.opts.MinZoom != 0 {
b.WriteString(fmt.Sprintf(`,minZoom:%s`, formatFloat(m.opts.MinZoom)))
}
if m.opts.MaxZoom != 0 {
b.WriteString(fmt.Sprintf(`,maxZoom:%s`, formatFloat(m.opts.MaxZoom)))
}
// Interaction toggles
writeBoolOpt := func(name string, val *bool) {
if val != nil {
if *val {
b.WriteString(fmt.Sprintf(`,%s:true`, name))
} else {
b.WriteString(fmt.Sprintf(`,%s:false`, name))
}
}
}
writeBoolOpt("scrollZoom", m.opts.ScrollZoom)
writeBoolOpt("boxZoom", m.opts.BoxZoom)
writeBoolOpt("dragRotate", m.opts.DragRotate)
writeBoolOpt("dragPan", m.opts.DragPan)
writeBoolOpt("keyboard", m.opts.Keyboard)
writeBoolOpt("doubleClickZoom", m.opts.DoubleClickZoom)
writeBoolOpt("touchZoomRotate", m.opts.TouchZoomRotate)
writeBoolOpt("touchPitch", m.opts.TouchPitch)
writeBoolOpt("renderWorldCopies", m.opts.RenderWorldCopies)
if m.opts.MaxBounds != nil {
b.WriteString(fmt.Sprintf(`,maxBounds:[[%s,%s],[%s,%s]]`,
formatFloat(m.opts.MaxBounds.SW.Lng), formatFloat(m.opts.MaxBounds.SW.Lat),
formatFloat(m.opts.MaxBounds.NE.Lng), formatFloat(m.opts.MaxBounds.NE.Lat)))
}
b.WriteString(`};`)
// Merge Extra options
if len(m.opts.Extra) > 0 {
extra, _ := json.Marshal(m.opts.Extra)
b.WriteString(fmt.Sprintf(`Object.assign(opts,%s);`, string(extra)))
}
b.WriteString(`var map=new maplibregl.Map(opts);`)
b.WriteString(`if(!window.__via_maps)window.__via_maps={};`)
b.WriteString(fmt.Sprintf(`window.__via_maps[%s]=map;`, jsonStr(m.id)))
b.WriteString(`map._via_markers={};map._via_popups={};map._via_controls={};`)
// Pre-render sources, layers, markers, popups, controls run on 'load'
hasLoad := len(m.sources) > 0 || len(m.layers) > 0 || len(m.markers) > 0 || len(m.popups) > 0 || len(m.controls) > 0
if hasLoad {
b.WriteString(`map.on('load',function(){`)
for _, src := range m.sources {
b.WriteString(fmt.Sprintf(`map.addSource(%s,%s);`, jsonStr(src.id), src.js))
}
for _, layer := range m.layers {
if layer.Before != "" {
b.WriteString(fmt.Sprintf(`map.addLayer(%s,%s);`, layer.toJS(), jsonStr(layer.Before)))
} else {
b.WriteString(fmt.Sprintf(`map.addLayer(%s);`, layer.toJS()))
}
}
for _, me := range m.markers {
b.WriteString(markerBodyJS(m.id, me.id, me.marker))
}
for _, pe := range m.popups {
b.WriteString(popupBodyJS(pe.id, pe.popup))
}
for _, ce := range m.controls {
b.WriteString(controlBodyJS(ce.id, ce.ctrl))
}
b.WriteString(`});`)
}
// Event listeners
for _, ev := range m.events {
b.WriteString(eventListenerJS(m.id, ev))
}
// Sync viewport signals on moveend via hidden inputs
b.WriteString(fmt.Sprintf(`map.on('moveend',function(){`+
`var c=map.getCenter();`+
`var el=document.getElementById(%[1]s);if(!el)return;`+
`var inputs=el.querySelectorAll('input[data-bind]');`+
`inputs.forEach(function(inp){`+
`var sig=inp.getAttribute('data-bind');`+
`if(sig===%[2]s)inp.value=c.lng;`+
`else if(sig===%[3]s)inp.value=c.lat;`+
`else if(sig===%[4]s)inp.value=map.getZoom();`+
`else if(sig===%[5]s)inp.value=map.getBearing();`+
`else if(sig===%[6]s)inp.value=map.getPitch();`+
`else return;`+
`inp.dispatchEvent(new Event('input',{bubbles:true}));`+
`});`+
`});`,
jsonStr("_vwrap_"+m.id),
jsonStr(m.CenterLng.ID()),
jsonStr(m.CenterLat.ID()),
jsonStr(m.Zoom.ID()),
jsonStr(m.Bearing.ID()),
jsonStr(m.Pitch.ID()),
))
// ResizeObserver for auto-resize
b.WriteString(fmt.Sprintf(
`var ro=new ResizeObserver(function(){map.resize();});`+
`ro.observe(document.getElementById(%s));`,
jsonStr("_vmap_"+m.id),
))
// MutationObserver to clean up on DOM removal (SPA nav)
b.WriteString(fmt.Sprintf(
`var container=document.getElementById(%[1]s);`+
`if(container){var mo=new MutationObserver(function(){`+
`if(!document.contains(container)){`+
`mo.disconnect();ro.disconnect();map.remove();`+
`delete window.__via_maps[%[2]s];`+
`}});`+
`mo.observe(document.body,{childList:true,subtree:true});}`,
jsonStr("_vmap_"+m.id),
jsonStr(m.id),
))
b.WriteString(`})()`)
return b.String()
}
// markerBodyJS generates JS to add a marker, assuming `map` is in scope.
func markerBodyJS(mapID, markerID string, mk Marker) string {
var b strings.Builder
opts := "{"
if mk.Color != "" {
opts += fmt.Sprintf(`color:%s,`, jsonStr(mk.Color))
}
if mk.Draggable {
opts += `draggable:true,`
}
opts += "}"
// Determine initial position
if mk.LngSignal != nil && mk.LatSignal != nil {
b.WriteString(fmt.Sprintf(`var mk=new maplibregl.Marker(%s).setLngLat([%s,%s]);`,
opts, mk.LngSignal.String(), mk.LatSignal.String()))
} else {
b.WriteString(fmt.Sprintf(`var mk=new maplibregl.Marker(%s).setLngLat([%s,%s]);`,
opts, formatFloat(mk.LngLat.Lng), formatFloat(mk.LngLat.Lat)))
}
if mk.Popup != nil {
b.WriteString(popupConstructorJS(*mk.Popup, "pk"))
b.WriteString(`mk.setPopup(pk);`)
}
b.WriteString(fmt.Sprintf(`mk.addTo(map);map._via_markers[%s]=mk;`, jsonStr(markerID)))
// Dragend → signal writeback
if mk.Draggable && mk.LngSignal != nil && mk.LatSignal != nil {
b.WriteString(dragendHandlerJS(mapID, markerID, mk))
}
return b.String()
}
// dragendHandlerJS generates JS that writes marker position back to signal hidden inputs on dragend.
func dragendHandlerJS(mapID, markerID string, mk Marker) string {
return fmt.Sprintf(
`mk.on('dragend',function(){`+
`var pos=mk.getLngLat();`+
`var el=document.getElementById(%[1]s);if(!el)return;`+
`var inputs=el.querySelectorAll('input[data-bind]');`+
`inputs.forEach(function(inp){`+
`var sig=inp.getAttribute('data-bind');`+
`if(sig===%[2]s){inp.value=pos.lng;inp.dispatchEvent(new Event('input',{bubbles:true}))}`+
`if(sig===%[3]s){inp.value=pos.lat;inp.dispatchEvent(new Event('input',{bubbles:true}))}`+
`});`+
`});`,
jsonStr("_vwrap_"+mapID),
jsonStr(mk.LngSignal.ID()),
jsonStr(mk.LatSignal.ID()),
)
}
// markerEffectExpr generates a data-effect expression that moves a signal-backed marker
// when its signals change.
func markerEffectExpr(mapID, markerID string, mk Marker) string {
// Read signals before the guard so Datastar tracks them as dependencies
// even when the map/marker hasn't loaded yet on first evaluation.
return fmt.Sprintf(
`var lng=$%s,lat=$%s;`+
`var m=window.__via_maps&&window.__via_maps[%s];`+
`if(m&&m._via_markers[%s]){`+
`m._via_markers[%s].setLngLat([lng,lat])}`,
mk.LngSignal.ID(), mk.LatSignal.ID(),
jsonStr(mapID), jsonStr(markerID), jsonStr(markerID),
)
}
// addMarkerJS generates a self-contained IIFE to add a marker post-render.
func addMarkerJS(mapID, markerID string, mk Marker) string {
var b strings.Builder
b.WriteString(fmt.Sprintf(
`(function(){var map=window.__via_maps&&window.__via_maps[%s];if(!map)return;`,
jsonStr(mapID)))
// Remove existing marker with same ID
b.WriteString(fmt.Sprintf(
`if(map._via_markers[%[1]s]){map._via_markers[%[1]s].remove();delete map._via_markers[%[1]s];}`,
jsonStr(markerID)))
b.WriteString(markerBodyJS(mapID, markerID, mk))
b.WriteString(`})()`)
return b.String()
}
// removeMarkerJS generates JS to remove a marker. Expects `m` in scope (used inside guard).
func removeMarkerJS(markerID string) string {
return fmt.Sprintf(
`if(m._via_markers[%[1]s]){m._via_markers[%[1]s].remove();delete m._via_markers[%[1]s];}`,
jsonStr(markerID))
}
// popupBodyJS generates JS to show a popup, assuming `map` is in scope.
func popupBodyJS(popupID string, p Popup) string {
var b strings.Builder
b.WriteString(popupConstructorJS(p, "p"))
b.WriteString(fmt.Sprintf(
`p.setLngLat([%s,%s]).addTo(map);map._via_popups[%s]=p;`,
formatFloat(p.LngLat.Lng), formatFloat(p.LngLat.Lat), jsonStr(popupID)))
return b.String()
}
// showPopupJS generates a self-contained IIFE to show a popup post-render.
func showPopupJS(mapID, popupID string, p Popup) string {
var b strings.Builder
b.WriteString(fmt.Sprintf(
`(function(){var map=window.__via_maps&&window.__via_maps[%s];if(!map)return;`,
jsonStr(mapID)))
// Close existing popup with same ID
b.WriteString(fmt.Sprintf(
`if(map._via_popups[%[1]s]){map._via_popups[%[1]s].remove();delete map._via_popups[%[1]s];}`,
jsonStr(popupID)))
b.WriteString(popupBodyJS(popupID, p))
b.WriteString(`})()`)
return b.String()
}
// closePopupJS generates JS to close a popup. Expects `m` in scope (used inside guard).
func closePopupJS(popupID string) string {
return fmt.Sprintf(
`if(m._via_popups[%[1]s]){m._via_popups[%[1]s].remove();delete m._via_popups[%[1]s];}`,
jsonStr(popupID))
}
// popupConstructorJS generates JS to create a Popup object stored in varName.
func popupConstructorJS(p Popup, varName string) string {
opts := "{"
if p.HideCloseButton {
opts += `closeButton:false,`
}
if p.MaxWidth != "" {
opts += fmt.Sprintf(`maxWidth:%s,`, jsonStr(p.MaxWidth))
}
opts += "}"
return fmt.Sprintf(`var %s=new maplibregl.Popup(%s).setHTML(%s);`,
varName, opts, jsonStr(p.Content))
}
// --- Control JS ---
// controlBodyJS generates JS to add a control, assuming `map` is in scope.
func controlBodyJS(controlID string, ctrl Control) string {
return fmt.Sprintf(
`var ctrl=%s;map.addControl(ctrl,%s);map._via_controls[%s]=ctrl;`,
ctrl.controlJS(), jsonStr(ctrl.controlPosition()), jsonStr(controlID))
}
// addControlJS generates a self-contained IIFE to add a control post-render.
func addControlJS(mapID, controlID string, ctrl Control) string {
return fmt.Sprintf(
`(function(){var map=window.__via_maps&&window.__via_maps[%[1]s];if(!map)return;`+
`if(map._via_controls[%[2]s]){map.removeControl(map._via_controls[%[2]s]);delete map._via_controls[%[2]s];}`+
`var ctrl=%[3]s;map.addControl(ctrl,%[4]s);map._via_controls[%[2]s]=ctrl;`+
`})()`,
jsonStr(mapID), jsonStr(controlID), ctrl.controlJS(), jsonStr(ctrl.controlPosition()))
}
// removeControlJS generates JS to remove a control. Expects `m` in scope.
func removeControlJS(controlID string) string {
return fmt.Sprintf(
`if(m._via_controls[%[1]s]){m.removeControl(m._via_controls[%[1]s]);delete m._via_controls[%[1]s];}`,
jsonStr(controlID))
}
// --- Event JS ---
// eventListenerJS generates JS to register a map event listener that writes
// event data to a hidden signal input.
func eventListenerJS(mapID string, ev eventEntry) string {
var handler string
if ev.layerID != "" {
handler = fmt.Sprintf(
`map.on(%[1]s,%[2]s,function(e){`+
`var d={lngLat:{Lng:e.lngLat.lng,Lat:e.lngLat.lat},point:[e.point.x,e.point.y],layerID:%[2]s};`+
`if(e.features)d.features=e.features.map(function(f){return JSON.parse(JSON.stringify(f))});`+
`var el=document.getElementById(%[3]s);if(!el)return;`+
`var inp=el.querySelector('input[data-bind=%[4]s]');`+
`if(inp){inp.value=JSON.stringify(d);inp.dispatchEvent(new Event('input',{bubbles:true}))}`+
`});`,
jsonStr(ev.event), jsonStr(ev.layerID),
jsonStr("_vwrap_"+mapID), jsonStr(ev.signal.ID()),
)
} else {
handler = fmt.Sprintf(
`map.on(%[1]s,function(e){`+
`var d={lngLat:{Lng:e.lngLat.lng,Lat:e.lngLat.lat},point:[e.point.x,e.point.y]};`+
`var el=document.getElementById(%[2]s);if(!el)return;`+
`var inp=el.querySelector('input[data-bind=%[3]s]');`+
`if(inp){inp.value=JSON.stringify(d);inp.dispatchEvent(new Event('input',{bubbles:true}))}`+
`});`,
jsonStr(ev.event),
jsonStr("_vwrap_"+mapID), jsonStr(ev.signal.ID()),
)
}
return handler
}
// --- Camera options JS ---
// cameraOptionsJS converts CameraOptions to a JS object literal string.
func cameraOptionsJS(opts CameraOptions) string {
obj := map[string]any{}
if opts.Center != nil {
obj["center"] = []float64{opts.Center.Lng, opts.Center.Lat}
}
if opts.Zoom != nil {
obj["zoom"] = *opts.Zoom
}
if opts.Bearing != nil {
obj["bearing"] = *opts.Bearing
}
if opts.Pitch != nil {
obj["pitch"] = *opts.Pitch
}
if opts.Duration != nil {
obj["duration"] = *opts.Duration
}
if opts.Speed != nil {
obj["speed"] = *opts.Speed
}
if opts.Curve != nil {
obj["curve"] = *opts.Curve
}
if opts.Padding != nil {
obj["padding"] = map[string]int{
"top": opts.Padding.Top,
"bottom": opts.Padding.Bottom,
"left": opts.Padding.Left,
"right": opts.Padding.Right,
}
}
if opts.Animate != nil {
obj["animate"] = *opts.Animate
}
b, _ := json.Marshal(obj)
return string(b)
}
func formatFloat(f float64) string {
return fmt.Sprintf("%g", f)
}

1
maplibre/maplibre-gl.css Normal file

File diff suppressed because one or more lines are too long

59
maplibre/maplibre-gl.js Normal file

File diff suppressed because one or more lines are too long

440
maplibre/maplibre.go Normal file
View File

@@ -0,0 +1,440 @@
// Package maplibre provides a Go API for MapLibre GL JS maps within Via applications.
//
// It follows the same ExecScript + DataIgnoreMorph pattern used for other client-side
// JS library integrations (e.g. ECharts in the realtimechart example).
package maplibre
import (
"crypto/rand"
_ "embed"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
//go:embed maplibre-gl.js
var maplibreJS []byte
//go:embed maplibre-gl.css
var maplibreCSS []byte
// Plugin serves the embedded MapLibre GL JS/CSS and injects them into the document head.
func Plugin(v *via.V) {
v.HTTPServeMux().HandleFunc("GET /_maplibre/maplibre-gl.js", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript")
_, _ = w.Write(maplibreJS)
})
v.HTTPServeMux().HandleFunc("GET /_maplibre/maplibre-gl.css", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css")
_, _ = w.Write(maplibreCSS)
})
v.AppendToHead(
h.Link(h.Rel("stylesheet"), h.Href("/_maplibre/maplibre-gl.css")),
h.Script(h.Src("/_maplibre/maplibre-gl.js")),
)
}
// Map represents a MapLibre GL map instance bound to a Via context.
type Map struct {
// Viewport signals — readable with .Text(), .String(), etc.
CenterLng *via.Signal
CenterLat *via.Signal
Zoom *via.Signal
Bearing *via.Signal
Pitch *via.Signal
id string
ctx *via.Context
opts Options
sources []sourceEntry
layers []Layer
markers []markerEntry
popups []popupEntry
events []eventEntry
controls []controlEntry
rendered bool
}
// New creates a Map bound to the given Via context with the provided options.
// It registers viewport signals on the context for browser → server sync.
func New(c *via.Context, opts Options) *Map {
if opts.Width == "" {
opts.Width = "100%"
}
if opts.Height == "" {
opts.Height = "400px"
}
m := &Map{
id: genID(),
ctx: c,
opts: opts,
}
m.CenterLng = c.Signal(opts.Center.Lng)
m.CenterLat = c.Signal(opts.Center.Lat)
m.Zoom = c.Signal(opts.Zoom)
m.Bearing = c.Signal(opts.Bearing)
m.Pitch = c.Signal(opts.Pitch)
return m
}
// Element returns the h.H DOM tree for the map. Call this once inside your View function.
// After Element() is called, subsequent source/layer/marker/popup operations
// use ExecScript instead of accumulating for the init script.
//
// Extra children are appended inside the wrapper div (useful for event inputs
// and data-effect binding elements).
func (m *Map) Element(extra ...h.H) h.H {
m.rendered = true
children := []h.H{
h.ID("_vwrap_" + m.id),
// Map container — morph-ignored so MapLibre's DOM isn't destroyed on Sync()
h.Div(
h.ID("_vmap_"+m.id),
h.DataIgnoreMorph(),
h.Attr("style", fmt.Sprintf("width:%s;height:%s", m.opts.Width, m.opts.Height)),
),
// Hidden inputs for viewport signal binding (outside morph-ignored zone)
h.Input(h.Type("hidden"), m.CenterLng.Bind()),
h.Input(h.Type("hidden"), m.CenterLat.Bind()),
h.Input(h.Type("hidden"), m.Zoom.Bind()),
h.Input(h.Type("hidden"), m.Bearing.Bind()),
h.Input(h.Type("hidden"), m.Pitch.Bind()),
}
// data-effect elements for signal-backed markers
for _, me := range m.markers {
if me.marker.LngSignal != nil && me.marker.LatSignal != nil {
children = append(children, h.Div(
h.Attr("style", "display:none"),
h.DataEffect(markerEffectExpr(m.id, me.id, me.marker)),
))
}
}
// Hidden inputs for signal-backed marker position writeback (drag → signal)
for _, me := range m.markers {
if me.marker.LngSignal != nil && me.marker.LatSignal != nil {
children = append(children,
h.Input(h.Type("hidden"), me.marker.LngSignal.Bind()),
h.Input(h.Type("hidden"), me.marker.LatSignal.Bind()),
)
}
}
children = append(children, extra...)
// Init script last
children = append(children, h.Script(h.Raw(initScript(m))))
return h.Div(children...)
}
// --- Viewport readers (signal → Go) ---
// Center returns the current map center from synced signals.
func (m *Map) Center() LngLat {
return LngLat{
Lng: parseFloat(m.CenterLng.String()),
Lat: parseFloat(m.CenterLat.String()),
}
}
// --- Camera methods ---
// FlyTo animates the map to the target camera state.
func (m *Map) FlyTo(opts CameraOptions) {
m.exec(fmt.Sprintf(`m.flyTo(%s);`, cameraOptionsJS(opts)))
}
// EaseTo eases the map to the target camera state.
func (m *Map) EaseTo(opts CameraOptions) {
m.exec(fmt.Sprintf(`m.easeTo(%s);`, cameraOptionsJS(opts)))
}
// JumpTo jumps the map to the target camera state without animation.
func (m *Map) JumpTo(opts CameraOptions) {
m.exec(fmt.Sprintf(`m.jumpTo(%s);`, cameraOptionsJS(opts)))
}
// FitBounds fits the map to the given bounds with optional camera options.
func (m *Map) FitBounds(bounds LngLatBounds, opts ...CameraOptions) {
boundsJS := fmt.Sprintf("[[%s,%s],[%s,%s]]",
formatFloat(bounds.SW.Lng), formatFloat(bounds.SW.Lat),
formatFloat(bounds.NE.Lng), formatFloat(bounds.NE.Lat))
if len(opts) > 0 {
m.exec(fmt.Sprintf(`m.fitBounds(%s,%s);`, boundsJS, cameraOptionsJS(opts[0])))
} else {
m.exec(fmt.Sprintf(`m.fitBounds(%s);`, boundsJS))
}
}
// Stop aborts any in-progress camera animation.
func (m *Map) Stop() {
m.exec(`m.stop();`)
}
// SetCenter sets the map center without animation.
func (m *Map) SetCenter(ll LngLat) {
m.exec(fmt.Sprintf(`m.setCenter([%s,%s]);`,
formatFloat(ll.Lng), formatFloat(ll.Lat)))
}
// SetZoom sets the map zoom level without animation.
func (m *Map) SetZoom(z float64) {
m.exec(fmt.Sprintf(`m.setZoom(%s);`, formatFloat(z)))
}
// SetBearing sets the map bearing without animation.
func (m *Map) SetBearing(b float64) {
m.exec(fmt.Sprintf(`m.setBearing(%s);`, formatFloat(b)))
}
// SetPitch sets the map pitch without animation.
func (m *Map) SetPitch(p float64) {
m.exec(fmt.Sprintf(`m.setPitch(%s);`, formatFloat(p)))
}
// SetStyle changes the map's style URL.
func (m *Map) SetStyle(url string) {
m.exec(fmt.Sprintf(`m.setStyle(%s);`, jsonStr(url)))
}
// --- Source methods ---
// AddSource adds a source to the map.
func (m *Map) AddSource(id string, src Source) {
js := src.sourceJS()
if !m.rendered {
m.sources = append(m.sources, sourceEntry{id: id, js: js})
return
}
m.exec(fmt.Sprintf(`m.addSource(%s,%s);`, jsonStr(id), js))
}
// RemoveSource removes a source from the map.
func (m *Map) RemoveSource(id string) {
if !m.rendered {
for i, s := range m.sources {
if s.id == id {
m.sources = append(m.sources[:i], m.sources[i+1:]...)
return
}
}
return
}
m.exec(fmt.Sprintf(`m.removeSource(%s);`, jsonStr(id)))
}
// UpdateGeoJSONSource replaces the data of an existing GeoJSON source.
func (m *Map) UpdateGeoJSONSource(sourceID string, data any) {
m.exec(fmt.Sprintf(`m.getSource(%s).setData(%s);`, jsonStr(sourceID), jsonVal(data)))
}
// --- Layer methods ---
// AddLayer adds a layer to the map.
func (m *Map) AddLayer(layer Layer) {
if !m.rendered {
m.layers = append(m.layers, layer)
return
}
before := "undefined"
if layer.Before != "" {
before = jsonStr(layer.Before)
}
m.exec(fmt.Sprintf(`m.addLayer(%s,%s);`, layer.toJS(), before))
}
// RemoveLayer removes a layer from the map.
func (m *Map) RemoveLayer(id string) {
if !m.rendered {
for i, l := range m.layers {
if l.ID == id {
m.layers = append(m.layers[:i], m.layers[i+1:]...)
return
}
}
return
}
m.exec(fmt.Sprintf(`m.removeLayer(%s);`, jsonStr(id)))
}
// SetPaintProperty sets a paint property on a layer.
func (m *Map) SetPaintProperty(layerID, name string, value any) {
m.exec(fmt.Sprintf(`m.setPaintProperty(%s,%s,%s);`,
jsonStr(layerID), jsonStr(name), jsonVal(value)))
}
// SetLayoutProperty sets a layout property on a layer.
func (m *Map) SetLayoutProperty(layerID, name string, value any) {
m.exec(fmt.Sprintf(`m.setLayoutProperty(%s,%s,%s);`,
jsonStr(layerID), jsonStr(name), jsonVal(value)))
}
// --- Marker methods ---
// AddMarker adds or replaces a marker on the map.
func (m *Map) AddMarker(id string, marker Marker) {
if !m.rendered {
m.markers = append(m.markers, markerEntry{id: id, marker: marker})
return
}
js := addMarkerJS(m.id, id, marker)
m.ctx.ExecScript(js)
}
// RemoveMarker removes a marker from the map.
func (m *Map) RemoveMarker(id string) {
if !m.rendered {
for i, me := range m.markers {
if me.id == id {
m.markers = append(m.markers[:i], m.markers[i+1:]...)
return
}
}
return
}
m.exec(removeMarkerJS(id))
}
// --- Popup methods ---
// ShowPopup shows a standalone popup on the map.
func (m *Map) ShowPopup(id string, popup Popup) {
if !m.rendered {
m.popups = append(m.popups, popupEntry{id: id, popup: popup})
return
}
js := showPopupJS(m.id, id, popup)
m.ctx.ExecScript(js)
}
// ClosePopup closes a standalone popup on the map.
func (m *Map) ClosePopup(id string) {
if !m.rendered {
for i, pe := range m.popups {
if pe.id == id {
m.popups = append(m.popups[:i], m.popups[i+1:]...)
return
}
}
return
}
m.exec(closePopupJS(id))
}
// --- Control methods ---
// AddControl adds a control to the map.
func (m *Map) AddControl(id string, ctrl Control) {
if !m.rendered {
m.controls = append(m.controls, controlEntry{id: id, ctrl: ctrl})
return
}
m.exec(addControlJS(m.id, id, ctrl))
}
// RemoveControl removes a control from the map.
func (m *Map) RemoveControl(id string) {
if !m.rendered {
for i, ce := range m.controls {
if ce.id == id {
m.controls = append(m.controls[:i], m.controls[i+1:]...)
return
}
}
return
}
m.exec(removeControlJS(id))
}
// --- Event methods ---
// OnClick returns a MapEvent that fires on map click.
func (m *Map) OnClick() *MapEvent {
return m.on("click", "")
}
// OnLayerClick returns a MapEvent that fires on click of a specific layer.
func (m *Map) OnLayerClick(layerID string) *MapEvent {
return m.on("click", layerID)
}
// OnMouseMove returns a MapEvent that fires on map mouse movement.
func (m *Map) OnMouseMove() *MapEvent {
return m.on("mousemove", "")
}
// OnContextMenu returns a MapEvent that fires on right-click.
func (m *Map) OnContextMenu() *MapEvent {
return m.on("contextmenu", "")
}
func (m *Map) on(event, layerID string) *MapEvent {
sig := m.ctx.Signal("")
ev := &MapEvent{signal: sig}
m.events = append(m.events, eventEntry{
event: event,
layerID: layerID,
signal: sig,
})
return ev
}
// --- Escape hatch ---
// Exec runs arbitrary JS with the map available as `m`.
func (m *Map) Exec(js string) {
m.exec(js)
}
// exec sends guarded JS to the browser via ExecScript.
func (m *Map) exec(body string) {
m.ctx.ExecScript(guard(m.id, body))
}
// --- MapEvent ---
// MapEvent wraps a signal that receives map event data as JSON.
type MapEvent struct {
signal *via.Signal
}
// Bind returns the data-bind attribute for this event's signal.
func (e *MapEvent) Bind() h.H { return e.signal.Bind() }
// Data parses the event signal's JSON value into EventData.
func (e *MapEvent) Data() EventData {
var d EventData
json.Unmarshal([]byte(e.signal.String()), &d)
return d
}
// Input creates a hidden input wired to this event's signal.
// Pass action triggers (e.g. handleClick.OnInput()) as attrs.
func (e *MapEvent) Input(attrs ...h.H) h.H {
all := append([]h.H{h.Type("hidden"), e.Bind()}, attrs...)
return h.Input(all...)
}
func parseFloat(s string) float64 {
f, _ := strconv.ParseFloat(s, 64)
return f
}
func genID() string {
b := make([]byte, 4)
rand.Read(b)
return hex.EncodeToString(b)
}

359
maplibre/types.go Normal file
View File

@@ -0,0 +1,359 @@
package maplibre
import (
"encoding/json"
"github.com/ryanhamamura/via"
)
// LngLat represents a geographic coordinate.
type LngLat struct {
Lng float64
Lat float64
}
// LngLatBounds represents a rectangular geographic area.
type LngLatBounds struct {
SW LngLat
NE LngLat
}
// Padding represents padding in pixels on each side of the map viewport.
type Padding struct {
Top int
Bottom int
Left int
Right int
}
// Options configures the initial map state.
type Options struct {
// Style is the map style URL (required).
Style string
Center LngLat
Zoom float64
Bearing float64
Pitch float64
MinZoom float64
MaxZoom float64
// CSS dimensions for the map container. Defaults: "100%", "400px".
Width string
Height string
// Interaction toggles (nil = MapLibre default)
ScrollZoom *bool
BoxZoom *bool
DragRotate *bool
DragPan *bool
Keyboard *bool
DoubleClickZoom *bool
TouchZoomRotate *bool
TouchPitch *bool
RenderWorldCopies *bool
MaxBounds *LngLatBounds
// Extra is merged last into the MapLibre constructor options object,
// allowing pass-through of any option not covered above.
Extra map[string]any
}
// --- Source interface ---
// Source is implemented by map data sources (GeoJSON, vector, raster, etc.).
type Source interface {
sourceJS() string
}
// GeoJSONSource provides inline GeoJSON data to MapLibre.
// Data should be a GeoJSON-marshalable value (struct, map, or json.RawMessage).
type GeoJSONSource struct {
Data any
}
func (s GeoJSONSource) sourceJS() string {
data, _ := json.Marshal(s.Data)
return `{"type":"geojson","data":` + string(data) + `}`
}
// VectorSource references a vector tile source.
type VectorSource struct {
URL string
Tiles []string
}
func (s VectorSource) sourceJS() string {
obj := map[string]any{"type": "vector"}
if s.URL != "" {
obj["url"] = s.URL
}
if len(s.Tiles) > 0 {
obj["tiles"] = s.Tiles
}
b, _ := json.Marshal(obj)
return string(b)
}
// RasterSource references a raster tile source.
type RasterSource struct {
URL string
Tiles []string
TileSize int
}
func (s RasterSource) sourceJS() string {
obj := map[string]any{"type": "raster"}
if s.URL != "" {
obj["url"] = s.URL
}
if len(s.Tiles) > 0 {
obj["tiles"] = s.Tiles
}
if s.TileSize > 0 {
obj["tileSize"] = s.TileSize
}
b, _ := json.Marshal(obj)
return string(b)
}
// RawSource is an escape hatch that passes an arbitrary JSON-marshalable
// value directly as a MapLibre source definition.
type RawSource struct {
Value any
}
func (s RawSource) sourceJS() string {
b, _ := json.Marshal(s.Value)
return string(b)
}
// --- Control interface ---
// Control is implemented by map controls (navigation, scale, etc.).
type Control interface {
controlJS() string
controlPosition() string
}
// NavigationControl adds zoom and rotation buttons.
type NavigationControl struct {
Position string // "top-right" (default), "top-left", "bottom-right", "bottom-left"
ShowCompass *bool
ShowZoom *bool
VisualizeRoll *bool
VisualizePitch *bool
}
func (c NavigationControl) controlJS() string {
opts := map[string]any{}
if c.ShowCompass != nil {
opts["showCompass"] = *c.ShowCompass
}
if c.ShowZoom != nil {
opts["showZoom"] = *c.ShowZoom
}
if c.VisualizeRoll != nil {
opts["visualizeRoll"] = *c.VisualizeRoll
}
if c.VisualizePitch != nil {
opts["visualizePitch"] = *c.VisualizePitch
}
b, _ := json.Marshal(opts)
return "new maplibregl.NavigationControl(" + string(b) + ")"
}
func (c NavigationControl) controlPosition() string {
if c.Position == "" {
return "top-right"
}
return c.Position
}
// ScaleControl displays a scale bar.
type ScaleControl struct {
Position string // default "bottom-left"
MaxWidth int
Unit string // "metric", "imperial", "nautical"
}
func (c ScaleControl) controlJS() string {
opts := map[string]any{}
if c.MaxWidth > 0 {
opts["maxWidth"] = c.MaxWidth
}
if c.Unit != "" {
opts["unit"] = c.Unit
}
b, _ := json.Marshal(opts)
return "new maplibregl.ScaleControl(" + string(b) + ")"
}
func (c ScaleControl) controlPosition() string {
if c.Position == "" {
return "bottom-left"
}
return c.Position
}
// GeolocateControl adds a button to track the user's location.
type GeolocateControl struct {
Position string // default "top-right"
}
func (c GeolocateControl) controlJS() string {
return "new maplibregl.GeolocateControl()"
}
func (c GeolocateControl) controlPosition() string {
if c.Position == "" {
return "top-right"
}
return c.Position
}
// FullscreenControl adds a fullscreen toggle button.
type FullscreenControl struct {
Position string // default "top-right"
}
func (c FullscreenControl) controlJS() string {
return "new maplibregl.FullscreenControl()"
}
func (c FullscreenControl) controlPosition() string {
if c.Position == "" {
return "top-right"
}
return c.Position
}
// --- Camera options ---
// CameraOptions configures animated camera movements (FlyTo, EaseTo, JumpTo).
// Nil pointer fields are omitted from the JS call.
type CameraOptions struct {
Center *LngLat
Zoom *float64
Bearing *float64
Pitch *float64
Duration *int // milliseconds
Speed *float64 // FlyTo only
Curve *float64 // FlyTo only
Padding *Padding
Animate *bool
}
// --- Layer ---
// Layer describes a MapLibre style layer.
type Layer struct {
ID string
Type string
Source string
SourceLayer string
Paint map[string]any
Layout map[string]any
Filter any
MinZoom float64
MaxZoom float64
// Before inserts this layer before the given layer ID in the stack.
Before string
}
func (l Layer) toJS() string {
obj := map[string]any{
"id": l.ID,
"type": l.Type,
}
if l.Source != "" {
obj["source"] = l.Source
}
if l.SourceLayer != "" {
obj["source-layer"] = l.SourceLayer
}
if l.Paint != nil {
obj["paint"] = l.Paint
}
if l.Layout != nil {
obj["layout"] = l.Layout
}
if l.Filter != nil {
obj["filter"] = l.Filter
}
if l.MinZoom > 0 {
obj["minzoom"] = l.MinZoom
}
if l.MaxZoom > 0 {
obj["maxzoom"] = l.MaxZoom
}
b, _ := json.Marshal(obj)
return string(b)
}
// --- Marker ---
// Marker describes a map marker.
type Marker struct {
LngLat LngLat // static position (used when signals are nil)
Color string
Draggable bool
Popup *Popup
// Signal-backed position. When set, signals drive marker position reactively.
// Initial position is read from the signal values. LngLat is ignored when signals are set.
// If Draggable is true, drag updates write back to these signals.
LngSignal *via.Signal
LatSignal *via.Signal
}
// Popup describes a map popup.
//
// Content is rendered as HTML via MapLibre's setHTML. Do not pass untrusted
// user input without sanitizing it first.
type Popup struct {
Content string // HTML content
LngLat LngLat
HideCloseButton bool // true removes the close button (MapLibre shows it by default)
MaxWidth string
}
// --- Event data ---
// EventData contains data from a map event (click, mousemove, etc.).
type EventData struct {
LngLat LngLat `json:"lngLat"`
Point [2]float64 `json:"point"`
Features []json.RawMessage `json:"features,omitempty"`
LayerID string `json:"layerID,omitempty"`
}
// --- Internal accumulation entries ---
type sourceEntry struct {
id string
js string
}
type markerEntry struct {
id string
marker Marker
}
type popupEntry struct {
id string
popup Popup
}
type eventEntry struct {
event string
layerID string
signal *via.Signal
}
type controlEntry struct {
id string
ctrl Control
}

10
nats.go
View File

@@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/delaneyj/toolbelt/embeddednats" "github.com/delaneyj/toolbelt/embeddednats"
natsserver "github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
) )
@@ -50,7 +51,14 @@ func startDefaultNATS() (dn *defaultNATS, err error) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
ns, err := embeddednats.New(ctx, embeddednats.WithDirectory(dataDir)) ns, err := embeddednats.New(ctx,
embeddednats.WithDirectory(dataDir),
embeddednats.WithNATSServerOptions(&natsserver.Options{
JetStream: true,
StoreDir: dataDir,
Port: -1,
}),
)
if err != nil { if err != nil {
cancel() cancel()
os.RemoveAll(dataDir) os.RemoveAll(dataDir)

View File

@@ -9,27 +9,27 @@ import (
) )
// Signal represents a value that is reactive in the browser. Signals // Signal represents a value that is reactive in the browser. Signals
// are synct with the server right before an action triggers. // are synced with the server right before an action triggers.
// //
// Use Bind() to connect a signal to an input and Text() to display it // Use Bind() to connect a signal to an input and Text() to display it
// reactively on an html element. // reactively on an html element.
type signal struct { type Signal struct {
id string id string
val any val any
changed bool changed bool
err error err error
} }
// ID returns the signal ID // ID returns the signal ID.
func (s *signal) ID() string { func (s *Signal) ID() string {
return s.id return s.id
} }
// Err returns a signal error or nil if it contains no error. // Err returns a signal error or nil if it contains no error.
// //
// It is useful to check for errors after updating signals with // It is useful to check for errors after updating signals with
// dinamic values. // dynamic values.
func (s *signal) Err() error { func (s *Signal) Err() error {
return s.err return s.err
} }
@@ -39,7 +39,7 @@ func (s *signal) Err() error {
// Example: // Example:
// //
// h.Input(h.Type("number"), mysignal.Bind()) // h.Input(h.Type("number"), mysignal.Bind())
func (s *signal) Bind() h.H { func (s *Signal) Bind() h.H {
return h.Data("bind", s.id) return h.Data("bind", s.id)
} }
@@ -48,33 +48,33 @@ func (s *signal) Bind() h.H {
// Example: // Example:
// //
// h.Div(mysignal.Text()) // h.Div(mysignal.Text())
func (s *signal) Text() h.H { func (s *Signal) Text() h.H {
return h.Span(h.Data("text", "$"+s.id)) return h.Span(h.Data("text", "$"+s.id))
} }
// SetValue updates the signals value and marks it for synchronization with the browser. // SetValue updates the signals value and marks it for synchronization with the browser.
// The change will be propagated to the browser using *Context.Sync() or *Context.SyncSignals(). // The change will be propagated to the browser using *Context.Sync() or *Context.SyncSignals().
func (s *signal) SetValue(v any) { func (s *Signal) SetValue(v any) {
s.val = v s.val = v
s.changed = true s.changed = true
s.err = nil s.err = nil
} }
// String return the signal value as a string. // String returns the signal value as a string.
func (s *signal) String() string { func (s *Signal) String() string {
return fmt.Sprintf("%v", s.val) return fmt.Sprintf("%v", s.val)
} }
// Bool tries to read the signal value as a bool. // Bool tries to read the signal value as a bool.
// Returns the value or false on failure. // Returns the value or false on failure.
func (s *signal) Bool() bool { func (s *Signal) Bool() bool {
val := strings.ToLower(s.String()) val := strings.ToLower(s.String())
return val == "true" || val == "1" || val == "yes" || val == "on" return val == "true" || val == "1" || val == "yes" || val == "on"
} }
// Int tries to read the signal value as an int. // Int tries to read the signal value as an int.
// Returns the value or 0 on failure. // Returns the value or 0 on failure.
func (s *signal) Int() int { func (s *Signal) Int() int {
if n, err := strconv.Atoi(s.String()); err == nil { if n, err := strconv.Atoi(s.String()); err == nil {
return n return n
} }

View File

@@ -27,7 +27,7 @@ func TestSignalReturnAsString(t *testing.T) {
for _, testcase := range testcases { for _, testcase := range testcases {
t.Run(testcase.desc, func(t *testing.T) { t.Run(testcase.desc, func(t *testing.T) {
t.Parallel() t.Parallel()
var sig *signal var sig *Signal
v := New() v := New()
v.Page("/", func(c *Context) { v.Page("/", func(c *Context) {
sig = c.Signal(testcase.given) sig = c.Signal(testcase.given)
@@ -57,7 +57,7 @@ func TestSignalReturnAsStringComplexTypes(t *testing.T) {
for _, testcase := range testcases { for _, testcase := range testcases {
t.Run(testcase.desc, func(t *testing.T) { t.Run(testcase.desc, func(t *testing.T) {
t.Parallel() t.Parallel()
var sig *signal var sig *Signal
v := New() v := New()
v.Page("/", func(c *Context) { v.Page("/", func(c *Context) {
c.View(func() h.H { return nil }) c.View(func() h.H { return nil })

37
via.go
View File

@@ -145,6 +145,9 @@ func (v *V) Config(cfg Options) {
if cfg.ContextTTL != 0 { if cfg.ContextTTL != 0 {
v.cfg.ContextTTL = cfg.ContextTTL v.cfg.ContextTTL = cfg.ContextTTL
} }
if cfg.Streams != nil {
v.cfg.Streams = cfg.Streams
}
if cfg.ActionRateLimit.Rate != 0 || cfg.ActionRateLimit.Burst != 0 { if cfg.ActionRateLimit.Rate != 0 || cfg.ActionRateLimit.Burst != 0 {
v.actionRateLimit = cfg.ActionRateLimit v.actionRateLimit = cfg.ActionRateLimit
} }
@@ -331,15 +334,18 @@ func (v *V) reapOrphanedContexts(suspendAfter, ttl time.Duration) {
if c.sseConnected.Load() { if c.sseConnected.Load() {
continue continue
} }
var disconnectedFor time.Duration // Use the most recent liveness signal
if dc := c.sseDisconnectedAt.Load(); dc != nil { lastAlive := c.createdAt
disconnectedFor = now.Sub(*dc) if dc := c.sseDisconnectedAt.Load(); dc != nil && dc.After(lastAlive) {
} else { lastAlive = *dc
disconnectedFor = now.Sub(c.createdAt)
} }
if disconnectedFor > ttl { if seen := c.lastSeenAt.Load(); seen != nil && seen.After(lastAlive) {
lastAlive = *seen
}
silentFor := now.Sub(lastAlive)
if silentFor > ttl {
toReap = append(toReap, c) toReap = append(toReap, c)
} else if disconnectedFor > suspendAfter && !c.suspended.Load() { } else if silentFor > suspendAfter && !c.suspended.Load() {
toSuspend = append(toSuspend, c) toSuspend = append(toSuspend, c)
} }
} }
@@ -368,6 +374,12 @@ func (v *V) Start() {
} }
} }
for _, sc := range v.cfg.Streams {
if err := EnsureStream(v, sc); err != nil {
v.logger.Fatal().Err(err).Msgf("failed to create stream %q", sc.Name)
}
}
handler := http.Handler(v.mux) handler := http.Handler(v.mux)
if v.sessionManager != nil { if v.sessionManager != nil {
handler = v.sessionManager.LoadAndSave(v.mux) handler = v.sessionManager.LoadAndSave(v.mux)
@@ -655,6 +667,8 @@ func New() *V {
return return
} }
c.reqCtx = r.Context() c.reqCtx = r.Context()
now := time.Now()
c.lastSeenAt.Store(&now)
sse := datastar.NewSSE(w, r, datastar.WithCompression(datastar.WithBrotli(datastar.WithBrotliLevel(5)))) sse := datastar.NewSSE(w, r, datastar.WithCompression(datastar.WithBrotli(datastar.WithBrotliLevel(5))))
@@ -688,17 +702,22 @@ func New() *V {
go c.Sync() go c.Sync()
keepalive := time.NewTicker(30 * time.Second)
defer keepalive.Stop()
for { for {
select { select {
case <-sse.Context().Done(): case <-sse.Context().Done():
v.logDebug(c, "SSE connection ended") v.logDebug(c, "SSE connection ended")
c.sseConnected.Store(false) c.sseConnected.Store(false)
now := time.Now() dcNow := time.Now()
c.sseDisconnectedAt.Store(&now) c.sseDisconnectedAt.Store(&dcNow)
return return
case <-c.ctxDisposedChan: case <-c.ctxDisposedChan:
v.logDebug(c, "context disposed, closing SSE") v.logDebug(c, "context disposed, closing SSE")
return return
case <-keepalive.C:
sse.PatchSignals([]byte("{}"))
case patch := <-c.patchChan: case patch := <-c.patchChan:
switch patch.typ { switch patch.typ {
case patchTypeElements: case patchTypeElements:

View File

@@ -90,7 +90,7 @@ func TestCustomDatastarPath(t *testing.T) {
} }
func TestSignal(t *testing.T) { func TestSignal(t *testing.T) {
var sig *signal var sig *Signal
v := New() v := New()
v.Page("/", func(c *Context) { v.Page("/", func(c *Context) {
sig = c.Signal("test") sig = c.Signal("test")
@@ -106,7 +106,7 @@ func TestSignal(t *testing.T) {
func TestAction(t *testing.T) { func TestAction(t *testing.T) {
var trigger *actionTrigger var trigger *actionTrigger
var sig *signal var sig *Signal
v := New() v := New()
v.Page("/", func(c *Context) { v.Page("/", func(c *Context) {
trigger = c.Action(func() {}) trigger = c.Action(func() {})
@@ -167,7 +167,7 @@ func TestEventTypes(t *testing.T) {
t.Run("WithSignal", func(t *testing.T) { t.Run("WithSignal", func(t *testing.T) {
var trigger *actionTrigger var trigger *actionTrigger
var sig *signal var sig *Signal
v := New() v := New()
v.Page("/", func(c *Context) { v.Page("/", func(c *Context) {
trigger = c.Action(func() {}) trigger = c.Action(func() {})
@@ -207,7 +207,7 @@ func TestOnKeyDownWithWindow(t *testing.T) {
func TestOnKeyDownMap(t *testing.T) { func TestOnKeyDownMap(t *testing.T) {
t.Run("multiple bindings with different actions", func(t *testing.T) { t.Run("multiple bindings with different actions", func(t *testing.T) {
var move, shoot *actionTrigger var move, shoot *actionTrigger
var dir *signal var dir *Signal
v := New() v := New()
v.Page("/", func(c *Context) { v.Page("/", func(c *Context) {
dir = c.Signal("none") dir = c.Signal("none")
@@ -251,7 +251,7 @@ func TestOnKeyDownMap(t *testing.T) {
t.Run("WithSignal per binding", func(t *testing.T) { t.Run("WithSignal per binding", func(t *testing.T) {
var move *actionTrigger var move *actionTrigger
var dir *signal var dir *Signal
v := New() v := New()
v.Page("/", func(c *Context) { v.Page("/", func(c *Context) {
dir = c.Signal("none") dir = c.Signal("none")
@@ -418,6 +418,7 @@ func TestReaperCleansOrphanedContexts(t *testing.T) {
func TestReaperSuspendsContext(t *testing.T) { func TestReaperSuspendsContext(t *testing.T) {
v := New() v := New()
c := newContext("suspend-1", "/", v) c := newContext("suspend-1", "/", v)
c.createdAt = time.Now().Add(-30 * time.Minute)
dc := time.Now().Add(-20 * time.Minute) dc := time.Now().Add(-20 * time.Minute)
c.sseDisconnectedAt.Store(&dc) c.sseDisconnectedAt.Store(&dc)
v.registerCtx(c) v.registerCtx(c)
@@ -432,6 +433,7 @@ func TestReaperSuspendsContext(t *testing.T) {
func TestReaperReapsAfterTTL(t *testing.T) { func TestReaperReapsAfterTTL(t *testing.T) {
v := New() v := New()
c := newContext("reap-1", "/", v) c := newContext("reap-1", "/", v)
c.createdAt = time.Now().Add(-3 * time.Hour)
dc := time.Now().Add(-2 * time.Hour) dc := time.Now().Add(-2 * time.Hour)
c.sseDisconnectedAt.Store(&dc) c.sseDisconnectedAt.Store(&dc)
c.suspended.Store(true) c.suspended.Store(true)
@@ -446,6 +448,7 @@ func TestReaperReapsAfterTTL(t *testing.T) {
func TestReaperIgnoresAlreadySuspended(t *testing.T) { func TestReaperIgnoresAlreadySuspended(t *testing.T) {
v := New() v := New()
c := newContext("already-sus-1", "/", v) c := newContext("already-sus-1", "/", v)
c.createdAt = time.Now().Add(-30 * time.Minute)
dc := time.Now().Add(-20 * time.Minute) dc := time.Now().Add(-20 * time.Minute)
c.sseDisconnectedAt.Store(&dc) c.sseDisconnectedAt.Store(&dc)
c.suspended.Store(true) c.suspended.Store(true)
@@ -500,6 +503,74 @@ func TestCleanupCtxIdempotent(t *testing.T) {
assert.Error(t, err, "context should be removed after cleanup") assert.Error(t, err, "context should be removed after cleanup")
} }
func TestReaperRespectsLastSeenAt(t *testing.T) {
v := New()
c := newContext("seen-1", "/", v)
c.createdAt = time.Now().Add(-30 * time.Minute)
// Disconnected 20 min ago, but client retried (lastSeenAt) 2 min ago
dc := time.Now().Add(-20 * time.Minute)
c.sseDisconnectedAt.Store(&dc)
seen := time.Now().Add(-2 * time.Minute)
c.lastSeenAt.Store(&seen)
v.registerCtx(c)
v.reapOrphanedContexts(15*time.Minute, time.Hour)
_, err := v.getCtx("seen-1")
assert.NoError(t, err, "context with recent lastSeenAt should survive suspend threshold")
assert.False(t, c.suspended.Load(), "context should not be suspended")
}
func TestReaperFallsBackWithoutLastSeenAt(t *testing.T) {
v := New()
c := newContext("noseen-1", "/", v)
c.createdAt = time.Now().Add(-30 * time.Minute)
dc := time.Now().Add(-20 * time.Minute)
c.sseDisconnectedAt.Store(&dc)
// no lastSeenAt set — should fall back to sseDisconnectedAt
v.registerCtx(c)
v.reapOrphanedContexts(15*time.Minute, time.Hour)
got, err := v.getCtx("noseen-1")
assert.NoError(t, err, "context should still be in registry (suspended, not reaped)")
assert.True(t, got.suspended.Load(), "context should be suspended using sseDisconnectedAt fallback")
}
func TestReaperReapsWithStaleLastSeenAt(t *testing.T) {
v := New()
c := newContext("stale-seen-1", "/", v)
c.createdAt = time.Now().Add(-3 * time.Hour)
dc := time.Now().Add(-2 * time.Hour)
c.sseDisconnectedAt.Store(&dc)
// lastSeenAt is also old — beyond TTL
seen := time.Now().Add(-90 * time.Minute)
c.lastSeenAt.Store(&seen)
c.suspended.Store(true)
v.registerCtx(c)
v.reapOrphanedContexts(15*time.Minute, time.Hour)
_, err := v.getCtx("stale-seen-1")
assert.Error(t, err, "context with stale lastSeenAt beyond TTL should be reaped")
}
func TestLastSeenAtUpdatedOnSSEConnect(t *testing.T) {
v := New()
c := newContext("seen-sse-1", "/", v)
v.registerCtx(c)
assert.Nil(t, c.lastSeenAt.Load(), "lastSeenAt should be nil before SSE connect")
// Simulate what the SSE handler does after getCtx
now := time.Now()
c.lastSeenAt.Store(&now)
got := c.lastSeenAt.Load()
assert.NotNil(t, got, "lastSeenAt should be set after SSE connect")
assert.WithinDuration(t, now, *got, time.Second)
}
func TestDevModeRemovePersistedFix(t *testing.T) { func TestDevModeRemovePersistedFix(t *testing.T) {
v := New() v := New()
v.cfg.DevMode = true v.cfg.DevMode = true