22 Commits

Author SHA1 Message Date
Ryan Hamamura
afd73e26de fix: add explicit --login and --repo flags to tea commands
All checks were successful
CI / Build and Test (push) Successful in 43s
2026-02-20 17:05:48 -10:00
5967ca3805 fix: prevent marker snap-back during drag via PubSub echo (#18)
All checks were successful
CI / Build and Test (push) Successful in 38s
2026-02-21 02:51:40 +00:00
63de5f997c fix: increase action rate limit for drag updates in maplibre example (#17)
All checks were successful
CI / Build and Test (push) Successful in 37s
2026-02-21 00:50:26 +00:00
453618f712 feat: complete Tier 4 marker/popup options, events, and live drag (#16)
All checks were successful
CI / Build and Test (push) Successful in 37s
2026-02-21 00:38:28 +00:00
15fda48844 fix: prevent custom element markers from rendering upside down (#15)
All checks were successful
CI / Build and Test (push) Successful in 37s
2026-02-20 21:33:27 +00:00
ae32da77df feat: add RotationSignal for reactive marker rotation (#14)
All checks were successful
CI / Build and Test (push) Successful in 37s
2026-02-20 21:16:40 +00:00
297808d4cc feat: animate fleet of container ships along bay waypoints (#13)
All checks were successful
CI / Build and Test (push) Successful in 36s
2026-02-20 21:10:24 +00:00
c2794fa0f9 style: simplify container ship SVG marker (#12)
All checks were successful
CI / Build and Test (push) Successful in 35s
2026-02-20 20:56:08 +00:00
7edd5ed1e6 style: replace simple ship icon with container vessel SVG (#11)
All checks were successful
CI / Build and Test (push) Successful in 36s
2026-02-20 20:53:04 +00:00
934805e707 feat: support custom HTML/SVG element markers in MapLibre (#10)
All checks were successful
CI / Build and Test (push) Successful in 34s
2026-02-20 20:40:19 +00:00
cbc5022e0d feat: sync all markers across clients in MapLibre example (#9)
All checks were successful
CI / Build and Test (push) Successful in 35s
2026-02-20 20:16:17 +00:00
74b32800f9 chore: gitignore nats-chatroom directory (#8)
Some checks failed
CI / Build and Test (push) Has been cancelled
2026-02-20 20:15:30 +00:00
cb13839157 fix: nil-close bug, stale docs, dead code, and tracked binaries (#7)
All checks were successful
CI / Build and Test (push) Successful in 34s
2026-02-20 20:00:44 +00:00
f833498b65 docs: clarify pr command step 8 for worktree usage (#6)
All checks were successful
CI / Build and Test (push) Successful in 37s
2026-02-20 19:39:26 +00:00
6064ddd856 style: normalize struct field alignment (#5)
All checks were successful
CI / Build and Test (push) Successful in 36s
2026-02-20 19:31:32 +00:00
dc56261b58 fix: remove context reaper to prevent background tabs from going stale (#4)
Some checks failed
CI / Build and Test (push) Failing after 35s
2026-02-20 19:11:12 +00:00
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
33 changed files with 1369 additions and 563 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 --login gitea --repo ryan/via --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 --login gitea --repo ryan/via <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. If in a worktree, `main` is checked out in the primary tree, so run from there: `cd <primary-worktree> && git pull gitea main && git push origin main` (the primary worktree path is the repo root without `.claude/worktrees/…`). If not in a worktree: `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 --login gitea --repo ryan/via` 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

21
.gitignore vendored
View File

@@ -37,18 +37,15 @@ go.work.sum
# Air artifacts # Air artifacts
*tmp/ *tmp/
# binaries # Example binaries and data files
internal/examples/chatroom/chatroom internal/examples/*/[a-z]*[!.go]
internal/examples/counter/counter internal/examples/shakespeare/shake.db
internal/examples/countercomp/countercomp
internal/examples/greeter/greeter
internal/examples/livereload/livereload
internal/examples/picocss/picocss
internal/examples/plugins/plugins
internal/examples/realtimechart/realtimechart
internal/examples/shakespeare/shakespeare
internal/examples/nats-chatroom/nats-chatroom
/nats-chatroom
# NATS data directory # NATS data directory
data/ data/
# Standalone experiments
nats-chatroom
# 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

@@ -74,14 +74,13 @@ func main() {
- **Plugin system** — `func(v *V)` hooks for integrating CSS/JS libraries - **Plugin system** — `func(v *V)` hooks for integrating CSS/JS libraries
- **Structured logging** — zerolog with configurable levels; console output in dev, JSON in production - **Structured logging** — zerolog with configurable levels; console output in dev, JSON in production
- **Graceful shutdown** — listens for SIGINT/SIGTERM, drains contexts, closes pub/sub - **Graceful shutdown** — listens for SIGINT/SIGTERM, drains contexts, closes pub/sub
- **Context lifecycle** — background reaper cleans up disconnected contexts; configurable TTL
- **HTML DSL** — the `h` package provides type-safe Go-native HTML composition - **HTML DSL** — the `h` package provides type-safe Go-native HTML composition
## Examples ## Examples
The `internal/examples/` directory contains 14 runnable examples: The `internal/examples/` directory contains 19 runnable examples:
`chatroom` · `counter` · `countercomp` · `greeter` · `keyboard` · `livereload` · `nats-chatroom` · `pathparams` · `picocss` · `plugins` · `pubsub-crud` · `realtimechart` · `session` · `shakespeare` `chatroom` · `counter` · `countercomp` · `effectspike` · `greeter` · `keyboard` · `livereload` · `maplibre` · `middleware` · `nats-chatroom` · `pathparams` · `picocss` · `plugins` · `pubsub-crud` · `realtimechart` · `session` · `shakespeare` · `signup` · `spa`
## Experimental ## Experimental

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),

View File

@@ -6,9 +6,13 @@ set -o pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT" cd "$ROOT"
echo "== CI: Format code ==" echo "== CI: Check formatting =="
go fmt ./... if [ -n "$(gofmt -l .)" ]; then
echo "OK: formatting complete" echo "ERROR: files not formatted:"
gofmt -l .
exit 1
fi
echo "OK: all files formatted"
echo "== CI: Run go vet ==" echo "== CI: Run go vet =="
if ! go vet ./...; then if ! go vet ./...; then

View File

@@ -1,7 +1,6 @@
package via package via
import ( import (
"fmt"
"strconv" "strconv"
"strings" "strings"
@@ -51,5 +50,5 @@ func (s *computedSignal) recompute() {
} }
func (s *computedSignal) patchValue() string { func (s *computedSignal) patchValue() string {
return fmt.Sprintf("%v", s.lastVal) return s.lastVal
} }

View File

@@ -26,7 +26,7 @@ func TestComputedBasic(t *testing.T) {
func TestComputedReactivity(t *testing.T) { func TestComputedReactivity(t *testing.T) {
v := New() v := New()
var cs *computedSignal var cs *computedSignal
var sig1 *signal var sig1 *Signal
v.Page("/", func(c *Context) { v.Page("/", func(c *Context) {
sig1 = c.Signal("a") sig1 = c.Signal("a")
sig2 := c.Signal("b") sig2 := c.Signal("b")
@@ -83,7 +83,7 @@ func TestComputedText(t *testing.T) {
func TestComputedChangeDetection(t *testing.T) { func TestComputedChangeDetection(t *testing.T) {
v := New() v := New()
var ctx *Context var ctx *Context
var sig *signal var sig *Signal
v.Page("/", func(c *Context) { v.Page("/", func(c *Context) {
ctx = c ctx = c
sig = c.Signal("a") sig = c.Signal("a")

View File

@@ -1,8 +1,6 @@
package via package via
import ( import (
"time"
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
@@ -62,16 +60,6 @@ type Options struct {
// the embedded NATS server. Ignored when a custom PubSub is configured. // the embedded NATS server. Ignored when a custom PubSub is configured.
Streams []StreamConfig Streams []StreamConfig
// ContextSuspendAfter is the time a context may be disconnected before
// the reaper suspends it (frees page resources but keeps the context
// shell for seamless re-init on reconnect). Default: 15m.
ContextSuspendAfter time.Duration
// ContextTTL is the maximum time a context may exist without an SSE
// connection before the background reaper fully disposes it.
// Default: 1h. Negative value disables the reaper.
ContextTTL time.Duration
// ActionRateLimit configures the default token-bucket rate limiter for // ActionRateLimit configures the default token-bucket rate limiter for
// action endpoints. Zero values use built-in defaults (10 req/s, burst 20). // action endpoints. Zero values use built-in defaults (10 req/s, burst 20).
// Set Rate to -1 to disable rate limiting entirely. // Set Rate to -1 to disable rate limiting entirely.

View File

@@ -42,8 +42,6 @@ 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
} }
// View defines the UI rendered by this context. // View defines the UI rendered by this context.
@@ -175,11 +173,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),
@@ -191,7 +189,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,
@@ -254,13 +252,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
} }
@@ -284,13 +282,14 @@ func (c *Context) prepareSignalsForPatch() map[string]any {
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 {
switch sig := value.(type) { switch sig := value.(type) {
case *signal: 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: case *computedSignal:
sig.recompute() sig.recompute()
@@ -443,13 +442,6 @@ func (c *Context) resetPageState() {
c.mu.Unlock() c.mu.Unlock()
} }
// suspend frees page-scoped resources while keeping the context shell alive
// in the registry for seamless re-init on reconnect.
func (c *Context) suspend() {
c.resetPageState()
c.suspended.Store(true)
}
// Navigate performs an SPA navigation to the given path. It resets page state, // Navigate performs an SPA navigation to the given path. It resets page state,
// runs the target page's init function (with middleware), and pushes the new // runs the target page's init function (with middleware), and pushes the new
// view over the existing SSE connection with a view transition animation. // view over the existing SSE connection with a view transition animation.
@@ -594,7 +586,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

@@ -66,7 +66,6 @@ v.Config(via.Options{
Plugins: []via.Plugin{MyPlugin}, Plugins: []via.Plugin{MyPlugin},
SessionManager: sm, SessionManager: sm,
PubSub: customBackend, PubSub: customBackend,
ContextTTL: 60 * time.Second,
ActionRateLimit: via.RateLimitConfig{Rate: 20, Burst: 40}, ActionRateLimit: via.RateLimitConfig{Rate: 20, Burst: 40},
}) })
``` ```
@@ -83,7 +82,6 @@ v.Config(via.Options{
| `DatastarContent` | (embedded) | Custom Datastar JS bytes | | `DatastarContent` | (embedded) | Custom Datastar JS bytes |
| `DatastarPath` | `"/_datastar.js"` | URL path for the Datastar script | | `DatastarPath` | `"/_datastar.js"` | URL path for the Datastar script |
| `PubSub` | embedded NATS | Custom PubSub backend. Replaces the default NATS. See [PubSub and Sessions](pubsub-and-sessions.md) | | `PubSub` | embedded NATS | Custom PubSub backend. Replaces the default NATS. See [PubSub and Sessions](pubsub-and-sessions.md) |
| `ContextTTL` | `30s` | Max time a context survives without an SSE connection before cleanup. Negative value disables the reaper |
| `ActionRateLimit` | `10 req/s, burst 20` | Default token-bucket rate limiter for action endpoints. Rate of `-1` disables limiting | | `ActionRateLimit` | `10 req/s, burst 20` | Default token-bucket rate limiter for action endpoints. Rate of `-1` disables limiting |
## Static Files ## Static Files

View File

@@ -14,7 +14,7 @@ Browser hits page → new Context created → init function runs → HTML render
action fires → signals injected from browser → handler runs → Sync() → DOM patched action fires → signals injected from browser → handler runs → Sync() → DOM patched
``` ```
The context is disposed when the SSE connection closes (tab close, navigation away, network loss). A background reaper also cleans up contexts that never establish an SSE connection within `ContextTTL` (default 30s). The context lives until the browser tab closes (detected via a `beforeunload` beacon) or the server shuts down. There is no background reaper — contexts persist across temporary SSE disconnections so backgrounded tabs resume seamlessly.
During [SPA navigation](routing-and-navigation.md#spa-navigation), the context itself survives — only page-level state (signals, actions, fields, intervals, subscriptions) is reset. The SSE connection persists. During [SPA navigation](routing-and-navigation.md#spa-navigation), the context itself survives — only page-level state (signals, actions, fields, intervals, subscriptions) is reset. The SSE connection persists.
@@ -94,7 +94,7 @@ Available triggers:
|--------|-------|-------| |--------|-------|-------|
| `OnClick()` | `click` | | | `OnClick()` | `click` | |
| `OnDblClick()` | `dblclick` | | | `OnDblClick()` | `dblclick` | |
| `OnChange()` | `change` | 200ms debounce | | `OnChange()` | `change` | |
| `OnInput()` | `input` | No debounce | | `OnInput()` | `input` | No debounce |
| `OnSubmit()` | `submit` | | | `OnSubmit()` | `submit` | |
| `OnKeyDown(key)` | `keydown` | Filtered by key name (e.g. `"Enter"`, `"Escape"`) | | `OnKeyDown(key)` | `keydown` | Filtered by key name (e.g. `"Enter"`, `"Escape"`) |

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

2
h/h.go
View File

@@ -5,7 +5,7 @@
// //
// h.Div( // h.Div(
// h.H1(h.Text("Hello, Via")), // h.H1(h.Text("Hello, Via")),
// h.P(h.Text("Pure Go. No tmplates.")), // h.P(h.Text("Pure Go. No templates.")),
// ) // )
package h package h

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

@@ -2,7 +2,6 @@ package main
import ( import (
"github.com/ryanhamamura/via" "github.com/ryanhamamura/via"
// "github.com/go-via/via-plugin-picocss/picocss"
"github.com/ryanhamamura/via/h" "github.com/ryanhamamura/via/h"
) )
@@ -15,9 +14,6 @@ func main() {
DocumentTitle: "Live Reload Demo", DocumentTitle: "Live Reload Demo",
DevMode: true, DevMode: true,
LogLevel: via.LogLevelDebug, LogLevel: via.LogLevelDebug,
Plugins: []via.Plugin{
// picocss.Default
},
}) })
v.Page("/", func(c *via.Context) { v.Page("/", func(c *via.Context) {

View File

@@ -2,12 +2,79 @@ package main
import ( import (
"fmt" "fmt"
"math"
"math/rand"
"strconv"
"sync"
"time"
"github.com/ryanhamamura/via" "github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h" "github.com/ryanhamamura/via/h"
"github.com/ryanhamamura/via/maplibre" "github.com/ryanhamamura/via/maplibre"
) )
type posMsg struct {
Lng float64 `json:"lng"`
Lat float64 `json:"lat"`
}
var (
vehicleOnce sync.Once
vehicle struct {
mu sync.RWMutex
lng, lat float64
}
)
// shipState tracks a ship lerping along a loop of waypoints.
type shipState struct {
lng, lat float64
waypoints [][2]float64 // [lng, lat] pairs
wpIdx int // index of next target waypoint
progress float64 // 0..1 toward next waypoint
speed float64 // progress increment per tick
}
func (s *shipState) tick() {
s.progress += s.speed
for s.progress >= 1 {
s.progress -= 1
s.wpIdx = (s.wpIdx + 1) % len(s.waypoints)
}
from := s.waypoints[(s.wpIdx-1+len(s.waypoints))%len(s.waypoints)]
to := s.waypoints[s.wpIdx]
s.lng = from[0] + (to[0]-from[0])*s.progress
s.lat = from[1] + (to[1]-from[1])*s.progress
}
// heading returns clockwise degrees from north (for SVG rotation).
func (s *shipState) heading() float64 {
from := s.waypoints[(s.wpIdx-1+len(s.waypoints))%len(s.waypoints)]
to := s.waypoints[s.wpIdx]
dx := to[0] - from[0]
dy := to[1] - from[1]
// atan2 gives angle from +X axis; convert to CW from north
return math.Mod(math.Atan2(dx, dy)*180/math.Pi+360, 360)
}
var (
fleetOnce sync.Once
fleet struct {
mu sync.RWMutex
ships [3]shipState
}
)
const shipSVG = `<svg width="48" height="28" viewBox="0 0 80 44" xmlns="http://www.w3.org/2000/svg">` +
`<path d="M2 30 L10 42 L70 42 L78 30 Z" fill="#1b3a5c"/>` +
`<rect x="12" y="24" width="56" height="6" rx="1" fill="#2c5f8a"/>` +
`<rect x="18" y="14" width="46" height="10" rx="1" fill="#2c5f8a" stroke="#1b3a5c" stroke-width="0.5"/>` +
`<rect x="8" y="12" width="9" height="12" rx="1" fill="#d5dfe8" stroke="#2c5f8a" stroke-width="0.5"/>` +
`<rect x="9.5" y="13" width="6" height="4" rx="0.5" fill="#85c1e9"/>` +
`<rect x="10" y="4" width="5" height="8" rx="0.5" fill="#1b3a5c"/>` +
`<rect x="9.5" y="2" width="6" height="3" rx="0.5" fill="#c0392b"/>` +
`</svg>`
func main() { func main() {
v := via.New() v := via.New()
v.Config(via.Options{ v.Config(via.Options{
@@ -15,6 +82,66 @@ func main() {
ServerAddress: ":7331", ServerAddress: ":7331",
DevMode: true, DevMode: true,
Plugins: []via.Plugin{maplibre.Plugin}, Plugins: []via.Plugin{maplibre.Plugin},
ActionRateLimit: via.RateLimitConfig{Rate: 60, Burst: 120},
})
// Single goroutine moves the vehicle — all clients read the same position.
vehicle.lng = -122.43
vehicle.lat = 37.77
vehicleOnce.Do(func() {
go func() {
for {
time.Sleep(time.Second)
vehicle.mu.Lock()
vehicle.lng = -122.43 + (rand.Float64()-0.5)*0.02
vehicle.lat = 37.77 + (rand.Float64()-0.5)*0.02
vehicle.mu.Unlock()
}
}()
})
// Fleet of ships following waypoint loops through SF Bay.
fleetOnce.Do(func() {
fleet.ships = [3]shipState{
{ // Golden Gate → Alcatraz → Pier 39 → back out
waypoints: [][2]float64{
{-122.478, 37.819}, {-122.423, 37.827},
{-122.410, 37.809}, {-122.423, 37.827},
},
speed: 0.03,
},
{ // Oakland → Treasure Island → Angel Island → loop
waypoints: [][2]float64{
{-122.330, 37.795}, {-122.370, 37.823},
{-122.432, 37.860}, {-122.370, 37.823},
},
speed: 0.02,
},
{ // Sausalito → Pier 39 ferry route
waypoints: [][2]float64{
{-122.480, 37.859}, {-122.435, 37.840},
{-122.410, 37.809}, {-122.435, 37.840},
},
speed: 0.025,
},
}
// Set initial positions.
for i := range fleet.ships {
s := &fleet.ships[i]
s.lng = s.waypoints[0][0]
s.lat = s.waypoints[0][1]
s.wpIdx = 1
}
go func() {
for {
time.Sleep(time.Second)
fleet.mu.Lock()
for i := range fleet.ships {
fleet.ships[i].tick()
}
fleet.mu.Unlock()
}
}()
}) })
v.Page("/", func(c *via.Context) { v.Page("/", func(c *via.Context) {
@@ -25,22 +152,131 @@ func main() {
Height: "500px", Height: "500px",
}) })
// Markers with popups m.AddControl("nav", maplibre.NavigationControl{})
m.AddControl("scale", maplibre.ScaleControl{Unit: "metric"})
// Static markers with popups
m.AddMarker("sf", maplibre.Marker{ m.AddMarker("sf", maplibre.Marker{
LngLat: maplibre.LngLat{Lng: -122.4194, Lat: 37.7749}, LngLat: maplibre.LngLat{Lng: -122.4194, Lat: 37.7749},
Color: "#e74c3c", Color: "#e74c3c",
Scale: 1.3,
Popup: &maplibre.Popup{ Popup: &maplibre.Popup{
Content: "<strong>San Francisco</strong><p>The Golden City</p>", Content: "<strong>San Francisco</strong><p>The Golden City</p>",
}, },
}) })
noCloseOnClick := false
m.AddMarker("oak", maplibre.Marker{ m.AddMarker("oak", maplibre.Marker{
LngLat: maplibre.LngLat{Lng: -122.2711, Lat: 37.8044}, LngLat: maplibre.LngLat{Lng: -122.2711, Lat: 37.8044},
Color: "#2ecc71", Color: "#2ecc71",
Opacity: 0.7,
Popup: &maplibre.Popup{ Popup: &maplibre.Popup{
Content: "<strong>Oakland</strong>", Content: "<strong>Oakland</strong>",
Anchor: "bottom",
Offset: [2]float64{0, -10},
CloseOnClick: &noCloseOnClick,
}, },
}) })
// Animated container ships following waypoint routes
shipNames := [3]string{"MSC Adriatica", "Evergreen Harmony", "Maersk Aurora"}
type shipSignals struct{ lng, lat, rot *via.Signal }
var ships [3]shipSignals
fleet.mu.RLock()
for i, s := range fleet.ships {
ships[i].lng = c.Signal(s.lng)
ships[i].lat = c.Signal(s.lat)
// SVG bow points right (east), so subtract 90° from the north-based heading.
ships[i].rot = c.Signal(s.heading() - 90)
m.AddMarker(fmt.Sprintf("ship-%d", i), maplibre.Marker{
LngSignal: ships[i].lng,
LatSignal: ships[i].lat,
RotationSignal: ships[i].rot,
Element: shipSVG,
Anchor: "center",
Popup: &maplibre.Popup{
Content: fmt.Sprintf("<strong>%s</strong>", shipNames[i]),
},
})
}
fleet.mu.RUnlock()
// Custom SVG vehicle marker — reads shared Go state
vehicleLng := c.Signal(-122.43)
vehicleLat := c.Signal(37.77)
m.AddMarker("vehicle", maplibre.Marker{
LngSignal: vehicleLng,
LatSignal: vehicleLat,
Element: `<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">` +
`<circle cx="10" cy="10" r="9" fill="#9b59b6" stroke="#fff" stroke-width="2"/>` +
`</svg>`,
Anchor: "center",
})
c.OnInterval(time.Second, func() {
vehicle.mu.RLock()
lng, lat := vehicle.lng, vehicle.lat
vehicle.mu.RUnlock()
vehicleLng.SetValue(lng)
vehicleLat.SetValue(lat)
fleet.mu.RLock()
for i, s := range fleet.ships {
ships[i].lng.SetValue(s.lng)
ships[i].lat.SetValue(s.lat)
ships[i].rot.SetValue(s.heading() - 90)
}
fleet.mu.RUnlock()
c.SyncSignals()
})
// Yellow click marker — synced across clients via PubSub
clickLng := c.Signal(-122.4194)
clickLat := c.Signal(37.7749)
m.AddMarker("clicked", maplibre.Marker{
LngSignal: clickLng,
LatSignal: clickLat,
Color: "#f39c12",
})
via.Subscribe(c, "map.click", func(msg posMsg) {
clickLng.SetValue(msg.Lng)
clickLat.SetValue(msg.Lat)
c.SyncSignals()
})
click := m.OnClick()
handleClick := c.Action(func() {
e := click.Data()
via.Publish(c, "map.click", posMsg{Lng: e.LngLat.Lng, Lat: e.LngLat.Lat})
})
// Blue draggable pin — synced across clients via PubSub
pinLng := c.Signal(-122.41)
pinLat := c.Signal(37.78)
m.AddMarker("pin", maplibre.Marker{
LngSignal: pinLng,
LatSignal: pinLat,
Color: "#3498db",
Draggable: true,
})
via.Subscribe(c, "map.pin", func(msg posMsg) {
pinLng.SetValue(msg.Lng)
pinLat.SetValue(msg.Lat)
c.SyncSignals()
})
handlePinDrag := c.Action(func() {
lng, _ := strconv.ParseFloat(pinLng.String(), 64)
lat, _ := strconv.ParseFloat(pinLat.String(), 64)
via.Publish(c, "map.pin", posMsg{Lng: lng, Lat: lat})
})
// GeoJSON polygon source + fill layer // GeoJSON polygon source + fill layer
m.AddSource("park", maplibre.GeoJSONSource{ m.AddSource("park", maplibre.GeoJSONSource{
Data: map[string]any{ Data: map[string]any{
@@ -70,24 +306,20 @@ func main() {
}, },
}) })
// Viewport info signal (updated on action) // FlyTo actions
viewportInfo := c.Signal("") zoom14 := 14.0
// FlyTo action
flyToSF := c.Action(func() { flyToSF := c.Action(func() {
m.FlyTo(maplibre.LngLat{Lng: -122.4194, Lat: 37.7749}, 14) m.FlyTo(maplibre.CameraOptions{
Center: &maplibre.LngLat{Lng: -122.4194, Lat: 37.7749},
Zoom: &zoom14,
})
}) })
flyToOak := c.Action(func() { flyToOak := c.Action(func() {
m.FlyTo(maplibre.LngLat{Lng: -122.2711, Lat: 37.8044}, 14) m.FlyTo(maplibre.CameraOptions{
Center: &maplibre.LngLat{Lng: -122.2711, Lat: 37.8044},
Zoom: &zoom14,
}) })
// Read viewport action
readViewport := c.Action(func() {
center := m.Center()
zoom := m.Zoom()
viewportInfo.SetValue(fmt.Sprintf("Center: %.4f, %.4f | Zoom: %.1f", center.Lng, center.Lat, zoom))
c.Sync()
}) })
c.View(func() h.H { c.View(func() h.H {
@@ -95,13 +327,21 @@ func main() {
h.Div( h.Div(
h.Attr("style", "max-width:960px;margin:0 auto;padding:1rem;font-family:sans-serif"), h.Attr("style", "max-width:960px;margin:0 auto;padding:1rem;font-family:sans-serif"),
h.H1(h.Text("MapLibre GL Example")), h.H1(h.Text("MapLibre GL Example")),
m.Element(), m.Element(
h.Div(h.Attr("style", "margin-top:1rem;display:flex;gap:0.5rem"), click.Input(handleClick.OnInput()),
h.Input(h.Type("hidden"), pinLng.Bind(), handlePinDrag.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 San Francisco"), flyToSF.OnClick()),
h.Button(h.Text("Fly to Oakland"), flyToOak.OnClick()), h.Button(h.Text("Fly to Oakland"), flyToOak.OnClick()),
h.Button(h.Text("Read Viewport"), readViewport.OnClick()),
), ),
h.P(viewportInfo.Text()), 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("Click: "), clickLng.Text(), h.Text(", "), clickLat.Text()),
h.P(h.Text("Vehicle: "), vehicleLng.Text(), h.Text(", "), vehicleLat.Text()),
h.P(h.Text("Draggable Pin: "), pinLng.Text(), h.Text(", "), pinLat.Text()),
),
), ),
) )
}) })

View File

@@ -4,17 +4,12 @@ import (
"strconv" "strconv"
"github.com/ryanhamamura/via" "github.com/ryanhamamura/via"
// "github.com/go-via/via-plugin-picocss/picocss"
. "github.com/ryanhamamura/via/h" . "github.com/ryanhamamura/via/h"
) )
func main() { func main() {
v := via.New() v := via.New()
v.Config(via.Options{
// Plugins: []via.Plugin{picocss.Default},
})
v.Page("/counters/{counter_id}/{start_at_step}", func(c *via.Context) { v.Page("/counters/{counter_id}/{start_at_step}", func(c *via.Context) {
counterID := c.GetPathParam("counter_id") counterID := c.GetPathParam("counter_id")

View File

@@ -2,7 +2,6 @@ package main
import ( import (
"github.com/ryanhamamura/via" "github.com/ryanhamamura/via"
// "github.com/go-via/via-plugin-picocss/picocss"
"github.com/ryanhamamura/via/h" "github.com/ryanhamamura/via/h"
) )
@@ -13,11 +12,6 @@ func main() {
v.Config(via.Options{ v.Config(via.Options{
DocumentTitle: "Via Counter", DocumentTitle: "Via Counter",
// Plugin is placed here. Use picocss.WithOptions(pococss.Options) to add the plugin
// with a different color theme or to enable a classes for a wide range of colors.
// Plugins: []via.Plugin{
// picocss.Default,
// },
}) })
v.Page("/", func(c *via.Context) { v.Page("/", func(c *via.Context) {

View File

@@ -6,7 +6,6 @@ import (
"time" "time"
"github.com/ryanhamamura/via" "github.com/ryanhamamura/via"
// "github.com/go-via/via-plugin-picocss/picocss"
"github.com/ryanhamamura/via/h" "github.com/ryanhamamura/via/h"
) )
@@ -16,9 +15,6 @@ func main() {
v.Config(via.Options{ v.Config(via.Options{
LogLevel: via.LogLevelDebug, LogLevel: via.LogLevelDebug,
DevMode: true, DevMode: true,
Plugins: []via.Plugin{
// picocss.Default,
},
}) })
v.AppendToHead( v.AppendToHead(

View File

@@ -6,9 +6,9 @@ import (
"log" "log"
"time" "time"
_ "github.com/mattn/go-sqlite3"
"github.com/ryanhamamura/via" "github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h" "github.com/ryanhamamura/via/h"
_ "github.com/mattn/go-sqlite3"
) )
type DataSource interface { type DataSource interface {
@@ -22,6 +22,9 @@ type ShakeDB struct {
findByTextStmt *sql.Stmt findByTextStmt *sql.Stmt
} }
// Prepare opens shake.db, a ~22 MB SQLite database of Shakespeare's works.
// Download from https://github.com/nicholasgasior/gopher-fizzbuzz/raw/master/shake.db
// and place it in this directory before running.
func (shakeDB *ShakeDB) Prepare() { func (shakeDB *ShakeDB) Prepare() {
db, err := sql.Open("sqlite3", "shake.db") db, err := sql.Open("sqlite3", "shake.db")
if err != nil { if err != nil {

View File

@@ -36,8 +36,9 @@ func initScript(m *Map) string {
jsonStr(m.id), jsonStr(m.id),
)) ))
// Build constructor options object
b.WriteString(fmt.Sprintf( b.WriteString(fmt.Sprintf(
`var map=new maplibregl.Map({container:%s,style:%s,center:[%s,%s],zoom:%s`, `var opts={container:%s,style:%s,center:[%s,%s],zoom:%s`,
jsonStr("_vmap_"+m.id), jsonStr("_vmap_"+m.id),
jsonStr(m.opts.Style), jsonStr(m.opts.Style),
formatFloat(m.opts.Center.Lng), formatFloat(m.opts.Center.Lng),
@@ -56,14 +57,49 @@ func initScript(m *Map) string {
if m.opts.MaxZoom != 0 { if m.opts.MaxZoom != 0 {
b.WriteString(fmt.Sprintf(`,maxZoom:%s`, formatFloat(m.opts.MaxZoom))) b.WriteString(fmt.Sprintf(`,maxZoom:%s`, formatFloat(m.opts.MaxZoom)))
} }
b.WriteString(`});`)
// 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(`if(!window.__via_maps)window.__via_maps={};`)
b.WriteString(fmt.Sprintf(`window.__via_maps[%s]=map;`, jsonStr(m.id))) b.WriteString(fmt.Sprintf(`window.__via_maps[%s]=map;`, jsonStr(m.id)))
b.WriteString(`map._via_markers={};map._via_popups={};`) b.WriteString(`map._via_markers={};map._via_popups={};map._via_controls={};`)
// Pre-render sources, layers, markers, popups run on 'load' // Pre-render sources, layers, markers, popups, controls run on 'load'
if len(m.sources) > 0 || len(m.layers) > 0 || len(m.markers) > 0 || len(m.popups) > 0 { 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(){`) b.WriteString(`map.on('load',function(){`)
for _, src := range m.sources { for _, src := range m.sources {
b.WriteString(fmt.Sprintf(`map.addSource(%s,%s);`, jsonStr(src.id), src.js)) b.WriteString(fmt.Sprintf(`map.addSource(%s,%s);`, jsonStr(src.id), src.js))
@@ -76,14 +112,32 @@ func initScript(m *Map) string {
} }
} }
for _, me := range m.markers { for _, me := range m.markers {
b.WriteString(markerBodyJS(me.id, me.marker)) b.WriteString(markerBodyJS(m.id, me.id, me.marker))
if me.handle != nil {
for _, ev := range me.handle.events {
b.WriteString(markerEventListenerJS(m.id, ev))
}
}
} }
for _, pe := range m.popups { for _, pe := range m.popups {
b.WriteString(popupBodyJS(pe.id, pe.popup)) b.WriteString(popupBodyJS(pe.id, pe.popup))
if pe.handle != nil {
for _, ev := range pe.handle.events {
b.WriteString(popupEventListenerJS(m.id, ev))
}
}
}
for _, ce := range m.controls {
b.WriteString(controlBodyJS(ce.id, ce.ctrl))
} }
b.WriteString(`});`) b.WriteString(`});`)
} }
// Event listeners
for _, ev := range m.events {
b.WriteString(eventListenerJS(m.id, ev))
}
// Sync viewport signals on moveend via hidden inputs // Sync viewport signals on moveend via hidden inputs
b.WriteString(fmt.Sprintf(`map.on('moveend',function(){`+ b.WriteString(fmt.Sprintf(`map.on('moveend',function(){`+
`var c=map.getCenter();`+ `var c=map.getCenter();`+
@@ -96,15 +150,16 @@ func initScript(m *Map) string {
`else if(sig===%[4]s)inp.value=map.getZoom();`+ `else if(sig===%[4]s)inp.value=map.getZoom();`+
`else if(sig===%[5]s)inp.value=map.getBearing();`+ `else if(sig===%[5]s)inp.value=map.getBearing();`+
`else if(sig===%[6]s)inp.value=map.getPitch();`+ `else if(sig===%[6]s)inp.value=map.getPitch();`+
`else return;`+
`inp.dispatchEvent(new Event('input',{bubbles:true}));`+ `inp.dispatchEvent(new Event('input',{bubbles:true}));`+
`});`+ `});`+
`});`, `});`,
jsonStr("_vwrap_"+m.id), jsonStr("_vwrap_"+m.id),
jsonStr(m.centerLng.ID()), jsonStr(m.CenterLng.ID()),
jsonStr(m.centerLat.ID()), jsonStr(m.CenterLat.ID()),
jsonStr(m.zoom.ID()), jsonStr(m.Zoom.ID()),
jsonStr(m.bearing.ID()), jsonStr(m.Bearing.ID()),
jsonStr(m.pitch.ID()), jsonStr(m.Pitch.ID()),
)) ))
// ResizeObserver for auto-resize // ResizeObserver for auto-resize
@@ -132,29 +187,163 @@ func initScript(m *Map) string {
} }
// markerBodyJS generates JS to add a marker, assuming `map` is in scope. // markerBodyJS generates JS to add a marker, assuming `map` is in scope.
// Used inside the init script's load callback. func markerBodyJS(mapID, markerID string, mk Marker) string {
func markerBodyJS(markerID string, mk Marker) string {
var b strings.Builder var b strings.Builder
// Use a wrapper div for custom elements so MapLibre rotates the div
// while we can independently flip the inner element to prevent inversion.
if mk.Element != "" {
b.WriteString(fmt.Sprintf(
`var _mkEl=document.createElement('div');`+
`_mkEl.style.display='inline-block';_mkEl.style.lineHeight='0';`+
`_mkEl.innerHTML=%s;`,
jsonStr(mk.Element)))
}
opts := "{" opts := "{"
if mk.Color != "" { if mk.Element != "" {
opts += `element:_mkEl,`
} else if mk.Color != "" {
opts += fmt.Sprintf(`color:%s,`, jsonStr(mk.Color)) opts += fmt.Sprintf(`color:%s,`, jsonStr(mk.Color))
} }
if mk.Anchor != "" {
opts += fmt.Sprintf(`anchor:%s,`, jsonStr(mk.Anchor))
}
// When both Element and RotationSignal are set, skip initial rotation
// in opts — we apply it post-creation with flip normalization.
if mk.RotationSignal != nil && mk.Element == "" {
opts += fmt.Sprintf(`rotation:%s,`, mk.RotationSignal.String())
} else if mk.RotationSignal == nil && mk.Rotation != 0 {
opts += fmt.Sprintf(`rotation:%s,`, formatFloat(mk.Rotation))
}
if mk.Draggable { if mk.Draggable {
opts += `draggable:true,` opts += `draggable:true,`
} }
if mk.Offset != [2]float64{} {
opts += fmt.Sprintf(`offset:[%s,%s],`, formatFloat(mk.Offset[0]), formatFloat(mk.Offset[1]))
}
if mk.Scale != 0 {
opts += fmt.Sprintf(`scale:%s,`, formatFloat(mk.Scale))
}
if mk.Opacity != 0 {
opts += fmt.Sprintf(`opacity:%s,`, formatFloat(mk.Opacity))
}
if mk.OpacityWhenCovered != 0 {
opts += fmt.Sprintf(`opacityWhenCovered:%s,`, formatFloat(mk.OpacityWhenCovered))
}
if mk.ClassName != "" {
opts += fmt.Sprintf(`className:%s,`, jsonStr(mk.ClassName))
}
opts += "}" 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]);`, b.WriteString(fmt.Sprintf(`var mk=new maplibregl.Marker(%s).setLngLat([%s,%s]);`,
opts, formatFloat(mk.LngLat.Lng), formatFloat(mk.LngLat.Lat))) opts, formatFloat(mk.LngLat.Lng), formatFloat(mk.LngLat.Lat)))
}
// Apply initial rotation with flip normalization for custom elements.
if mk.RotationSignal != nil && mk.Element != "" {
b.WriteString(fmt.Sprintf(
`var _r=%s,_f=_r>90||_r<-90;if(_f)_r=_r>0?_r-180:_r+180;`+
`mk.setRotation(_r);`+
`var _ch=_mkEl.firstElementChild;if(_ch&&_f)_ch.style.transform='scaleX(-1)';`,
mk.RotationSignal.String()))
}
if mk.Popup != nil { if mk.Popup != nil {
b.WriteString(popupConstructorJS(*mk.Popup, "pk")) b.WriteString(popupConstructorJS(*mk.Popup, "pk"))
b.WriteString(`mk.setPopup(pk);`) b.WriteString(`mk.setPopup(pk);`)
} }
b.WriteString(fmt.Sprintf(`mk.addTo(map);map._via_markers[%s]=mk;`, jsonStr(markerID))) b.WriteString(fmt.Sprintf(`mk.addTo(map);map._via_markers[%s]=mk;`, jsonStr(markerID)))
// Drag → throttled live signal writeback + dragend final writeback
if mk.Draggable && mk.LngSignal != nil && mk.LatSignal != nil {
b.WriteString(dragHandlerJS(mapID, mk))
}
return b.String()
}
// dragHandlerJS generates JS that writes marker position to signal hidden inputs
// during drag (throttled via requestAnimationFrame) and on dragend (unthrottled).
func dragHandlerJS(mapID string, mk Marker) string {
// Shared writeback logic extracted into a local function for both handlers.
return fmt.Sprintf(
`var _raf=0;mk._dragging=false;`+
`function _wb(){`+
`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}))}`+
`});`+
`}`+
`mk.on('dragstart',function(){mk._dragging=true;});`+
`mk.on('drag',function(){if(_raf)return;_raf=requestAnimationFrame(function(){_raf=0;_wb()});});`+
`mk.on('dragend',function(){cancelAnimationFrame(_raf);_raf=0;_wb();mk._dragging=false;});`,
jsonStr("_vwrap_"+mapID),
jsonStr(mk.LngSignal.ID()),
jsonStr(mk.LatSignal.ID()),
)
}
// markerEventListenerJS generates JS for a marker event listener.
// Assumes `mk` (the marker) is in scope.
func markerEventListenerJS(mapID string, ev markerEventEntry) string {
return fmt.Sprintf(
`mk.on(%[1]s,function(){`+
`var pos=mk.getLngLat();`+
`var d={lngLat:{Lng:pos.lng,Lat:pos.lat},point:[0,0]};`+
`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()),
)
}
// 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.
var b strings.Builder
b.WriteString(fmt.Sprintf(`var lng=$%s,lat=$%s;`, mk.LngSignal.ID(), mk.LatSignal.ID()))
if mk.RotationSignal != nil {
b.WriteString(fmt.Sprintf(`var rot=$%s;`, mk.RotationSignal.ID()))
}
b.WriteString(fmt.Sprintf(
`var m=window.__via_maps&&window.__via_maps[%s];`+
`if(m&&m._via_markers[%[2]s]&&!m._via_markers[%[2]s]._dragging){`+
`m._via_markers[%[2]s].setLngLat([lng,lat])`,
jsonStr(mapID), jsonStr(markerID)))
if mk.RotationSignal != nil && mk.Element != "" {
// Normalize rotation to [-90,90] and horizontally flip the inner
// element when |rotation| > 90° to prevent upside-down markers.
b.WriteString(fmt.Sprintf(
`;var _mk=m._via_markers[%[1]s],_f=rot>90||rot<-90;`+
`if(_f)rot=rot>0?rot-180:rot+180;`+
`_mk.setRotation(rot);`+
`var _ch=_mk.getElement().firstElementChild;`+
`if(_ch)_ch.style.transform=_f?'scaleX(-1)':''`,
jsonStr(markerID)))
} else if mk.RotationSignal != nil {
b.WriteString(fmt.Sprintf(`;m._via_markers[%s].setRotation(rot)`, jsonStr(markerID)))
}
b.WriteString(`}`)
return b.String() return b.String()
} }
// addMarkerJS generates a self-contained IIFE to add a marker post-render. // addMarkerJS generates a self-contained IIFE to add a marker post-render.
func addMarkerJS(mapID, markerID string, mk Marker) string { func addMarkerJS(mapID, markerID string, mk Marker, events []markerEventEntry) string {
var b strings.Builder var b strings.Builder
b.WriteString(fmt.Sprintf( b.WriteString(fmt.Sprintf(
`(function(){var map=window.__via_maps&&window.__via_maps[%s];if(!map)return;`, `(function(){var map=window.__via_maps&&window.__via_maps[%s];if(!map)return;`,
@@ -163,7 +352,10 @@ func addMarkerJS(mapID, markerID string, mk Marker) string {
b.WriteString(fmt.Sprintf( b.WriteString(fmt.Sprintf(
`if(map._via_markers[%[1]s]){map._via_markers[%[1]s].remove();delete map._via_markers[%[1]s];}`, `if(map._via_markers[%[1]s]){map._via_markers[%[1]s].remove();delete map._via_markers[%[1]s];}`,
jsonStr(markerID))) jsonStr(markerID)))
b.WriteString(markerBodyJS(markerID, mk)) b.WriteString(markerBodyJS(mapID, markerID, mk))
for _, ev := range events {
b.WriteString(markerEventListenerJS(mapID, ev))
}
b.WriteString(`})()`) b.WriteString(`})()`)
return b.String() return b.String()
} }
@@ -186,7 +378,7 @@ func popupBodyJS(popupID string, p Popup) string {
} }
// showPopupJS generates a self-contained IIFE to show a popup post-render. // showPopupJS generates a self-contained IIFE to show a popup post-render.
func showPopupJS(mapID, popupID string, p Popup) string { func showPopupJS(mapID, popupID string, p Popup, events []popupEventEntry) string {
var b strings.Builder var b strings.Builder
b.WriteString(fmt.Sprintf( b.WriteString(fmt.Sprintf(
`(function(){var map=window.__via_maps&&window.__via_maps[%s];if(!map)return;`, `(function(){var map=window.__via_maps&&window.__via_maps[%s];if(!map)return;`,
@@ -196,6 +388,9 @@ func showPopupJS(mapID, popupID string, p Popup) string {
`if(map._via_popups[%[1]s]){map._via_popups[%[1]s].remove();delete map._via_popups[%[1]s];}`, `if(map._via_popups[%[1]s]){map._via_popups[%[1]s].remove();delete map._via_popups[%[1]s];}`,
jsonStr(popupID))) jsonStr(popupID)))
b.WriteString(popupBodyJS(popupID, p)) b.WriteString(popupBodyJS(popupID, p))
for _, ev := range events {
b.WriteString(popupEventListenerJS(mapID, ev))
}
b.WriteString(`})()`) b.WriteString(`})()`)
return b.String() return b.String()
} }
@@ -216,11 +411,150 @@ func popupConstructorJS(p Popup, varName string) string {
if p.MaxWidth != "" { if p.MaxWidth != "" {
opts += fmt.Sprintf(`maxWidth:%s,`, jsonStr(p.MaxWidth)) opts += fmt.Sprintf(`maxWidth:%s,`, jsonStr(p.MaxWidth))
} }
if p.CloseOnClick != nil {
if *p.CloseOnClick {
opts += `closeOnClick:true,`
} else {
opts += `closeOnClick:false,`
}
}
if p.CloseOnMove != nil {
if *p.CloseOnMove {
opts += `closeOnMove:true,`
} else {
opts += `closeOnMove:false,`
}
}
if p.Anchor != "" {
opts += fmt.Sprintf(`anchor:%s,`, jsonStr(p.Anchor))
}
if p.Offset != [2]float64{} {
opts += fmt.Sprintf(`offset:[%s,%s],`, formatFloat(p.Offset[0]), formatFloat(p.Offset[1]))
}
if p.ClassName != "" {
opts += fmt.Sprintf(`className:%s,`, jsonStr(p.ClassName))
}
opts += "}" opts += "}"
return fmt.Sprintf(`var %s=new maplibregl.Popup(%s).setHTML(%s);`, return fmt.Sprintf(`var %s=new maplibregl.Popup(%s).setHTML(%s);`,
varName, opts, jsonStr(p.Content)) varName, opts, jsonStr(p.Content))
} }
// popupEventListenerJS generates JS for a popup event listener.
// Assumes `p` (the popup) is in scope.
func popupEventListenerJS(mapID string, ev popupEventEntry) string {
// open/close carry no spatial data — write a timestamp as change trigger.
return fmt.Sprintf(
`p.on(%[1]s,function(){`+
`var el=document.getElementById(%[2]s);if(!el)return;`+
`var inp=el.querySelector('input[data-bind=%[3]s]');`+
`if(inp){inp.value=Date.now();inp.dispatchEvent(new Event('input',{bubbles:true}))}`+
`});`,
jsonStr(ev.event),
jsonStr("_vwrap_"+mapID),
jsonStr(ev.signal.ID()),
)
}
// --- 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 { func formatFloat(f float64) string {
return fmt.Sprintf("%g", f) return fmt.Sprintf("%g", f)
} }

View File

@@ -8,6 +8,7 @@ import (
"crypto/rand" "crypto/rand"
_ "embed" _ "embed"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
@@ -38,29 +39,25 @@ func Plugin(v *via.V) {
) )
} }
// viaSignal is the interface satisfied by via's *signal type.
type viaSignal interface {
ID() string
String() string
SetValue(any)
Bind() h.H
}
// Map represents a MapLibre GL map instance bound to a Via context. // Map represents a MapLibre GL map instance bound to a Via context.
type Map struct { 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 id string
ctx *via.Context ctx *via.Context
opts Options opts Options
// Viewport signals for browser → server sync
centerLng, centerLat viaSignal
zoom, bearing, pitch viaSignal
// Pre-render accumulation
sources []sourceEntry sources []sourceEntry
layers []Layer layers []Layer
markers []markerEntry markers []markerEntry
popups []popupEntry popups []popupEntry
events []eventEntry
controls []controlEntry
rendered bool rendered bool
} }
@@ -81,11 +78,11 @@ func New(c *via.Context, opts Options) *Map {
opts: opts, opts: opts,
} }
m.centerLng = c.Signal(opts.Center.Lng) m.CenterLng = c.Signal(opts.Center.Lng)
m.centerLat = c.Signal(opts.Center.Lat) m.CenterLat = c.Signal(opts.Center.Lat)
m.zoom = c.Signal(opts.Zoom) m.Zoom = c.Signal(opts.Zoom)
m.bearing = c.Signal(opts.Bearing) m.Bearing = c.Signal(opts.Bearing)
m.pitch = c.Signal(opts.Pitch) m.Pitch = c.Signal(opts.Pitch)
return m return m
} }
@@ -93,10 +90,14 @@ func New(c *via.Context, opts Options) *Map {
// Element returns the h.H DOM tree for the map. Call this once inside your View function. // 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 // After Element() is called, subsequent source/layer/marker/popup operations
// use ExecScript instead of accumulating for the init script. // use ExecScript instead of accumulating for the init script.
func (m *Map) Element() h.H { //
// 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 m.rendered = true
return h.Div(h.ID("_vwrap_"+m.id), children := []h.H{
h.ID("_vwrap_" + m.id),
// Map container — morph-ignored so MapLibre's DOM isn't destroyed on Sync() // Map container — morph-ignored so MapLibre's DOM isn't destroyed on Sync()
h.Div( h.Div(
h.ID("_vmap_"+m.id), h.ID("_vmap_"+m.id),
@@ -104,14 +105,63 @@ func (m *Map) Element() h.H {
h.Attr("style", fmt.Sprintf("width:%s;height:%s", m.opts.Width, m.opts.Height)), 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) // Hidden inputs for viewport signal binding (outside morph-ignored zone)
h.Input(h.Type("hidden"), m.centerLng.Bind()), h.Input(h.Type("hidden"), m.CenterLng.Bind()),
h.Input(h.Type("hidden"), m.centerLat.Bind()), h.Input(h.Type("hidden"), m.CenterLat.Bind()),
h.Input(h.Type("hidden"), m.zoom.Bind()), h.Input(h.Type("hidden"), m.Zoom.Bind()),
h.Input(h.Type("hidden"), m.bearing.Bind()), h.Input(h.Type("hidden"), m.Bearing.Bind()),
h.Input(h.Type("hidden"), m.pitch.Bind()), h.Input(h.Type("hidden"), m.Pitch.Bind()),
// Init script }
h.Script(h.Raw(initScript(m))),
// 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/rotation writeback
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()),
) )
if me.marker.RotationSignal != nil {
children = append(children,
h.Input(h.Type("hidden"), me.marker.RotationSignal.Bind()),
)
}
}
// Hidden inputs for marker event signals
if me.handle != nil {
for _, ev := range me.handle.events {
children = append(children,
h.Input(h.Type("hidden"), ev.signal.Bind()),
)
}
}
}
// Hidden inputs for popup event signals
for _, pe := range m.popups {
if pe.handle != nil {
for _, ev := range pe.handle.events {
children = append(children,
h.Input(h.Type("hidden"), ev.signal.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) --- // --- Viewport readers (signal → Go) ---
@@ -119,32 +169,43 @@ func (m *Map) Element() h.H {
// Center returns the current map center from synced signals. // Center returns the current map center from synced signals.
func (m *Map) Center() LngLat { func (m *Map) Center() LngLat {
return LngLat{ return LngLat{
Lng: parseFloat(m.centerLng.String()), Lng: parseFloat(m.CenterLng.String()),
Lat: parseFloat(m.centerLat.String()), Lat: parseFloat(m.CenterLat.String()),
} }
} }
// Zoom returns the current map zoom level from the synced signal. // --- Camera methods ---
func (m *Map) Zoom() float64 {
return parseFloat(m.zoom.String()) // FlyTo animates the map to the target camera state.
func (m *Map) FlyTo(opts CameraOptions) {
m.exec(fmt.Sprintf(`m.flyTo(%s);`, cameraOptionsJS(opts)))
} }
// Bearing returns the current map bearing from the synced signal. // EaseTo eases the map to the target camera state.
func (m *Map) Bearing() float64 { func (m *Map) EaseTo(opts CameraOptions) {
return parseFloat(m.bearing.String()) m.exec(fmt.Sprintf(`m.easeTo(%s);`, cameraOptionsJS(opts)))
} }
// Pitch returns the current map pitch from the synced signal. // JumpTo jumps the map to the target camera state without animation.
func (m *Map) Pitch() float64 { func (m *Map) JumpTo(opts CameraOptions) {
return parseFloat(m.pitch.String()) m.exec(fmt.Sprintf(`m.jumpTo(%s);`, cameraOptionsJS(opts)))
} }
// --- Viewport setters (Go → browser) --- // 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))
}
}
// FlyTo animates the map to the given center and zoom. // Stop aborts any in-progress camera animation.
func (m *Map) FlyTo(center LngLat, zoom float64) { func (m *Map) Stop() {
m.exec(fmt.Sprintf(`m.flyTo({center:[%s,%s],zoom:%s});`, m.exec(`m.stop();`)
formatFloat(center.Lng), formatFloat(center.Lat), formatFloat(zoom)))
} }
// SetCenter sets the map center without animation. // SetCenter sets the map center without animation.
@@ -175,10 +236,9 @@ func (m *Map) SetStyle(url string) {
// --- Source methods --- // --- Source methods ---
// AddSource adds a source to the map. src should be a GeoJSONSource, // AddSource adds a source to the map.
// VectorSource, RasterSource, or any JSON-marshalable value. func (m *Map) AddSource(id string, src Source) {
func (m *Map) AddSource(id string, src any) { js := src.sourceJS()
js := sourceJSON(src)
if !m.rendered { if !m.rendered {
m.sources = append(m.sources, sourceEntry{id: id, js: js}) m.sources = append(m.sources, sourceEntry{id: id, js: js})
return return
@@ -187,7 +247,6 @@ func (m *Map) AddSource(id string, src any) {
} }
// RemoveSource removes a source from the map. // RemoveSource removes a source from the map.
// Before render, it removes a previously accumulated source. After render, it issues an ExecScript.
func (m *Map) RemoveSource(id string) { func (m *Map) RemoveSource(id string) {
if !m.rendered { if !m.rendered {
for i, s := range m.sources { for i, s := range m.sources {
@@ -222,7 +281,6 @@ func (m *Map) AddLayer(layer Layer) {
} }
// RemoveLayer removes a layer from the map. // RemoveLayer removes a layer from the map.
// Before render, it removes a previously accumulated layer. After render, it issues an ExecScript.
func (m *Map) RemoveLayer(id string) { func (m *Map) RemoveLayer(id string) {
if !m.rendered { if !m.rendered {
for i, l := range m.layers { for i, l := range m.layers {
@@ -251,17 +309,46 @@ func (m *Map) SetLayoutProperty(layerID, name string, value any) {
// --- Marker methods --- // --- Marker methods ---
// AddMarker adds or replaces a marker on the map. // AddMarker adds or replaces a marker on the map.
func (m *Map) AddMarker(id string, marker Marker) { // The returned MarkerHandle can be used to subscribe to marker-level events.
func (m *Map) AddMarker(id string, marker Marker) *MarkerHandle {
h := &MarkerHandle{markerID: id, m: m}
if !m.rendered { if !m.rendered {
m.markers = append(m.markers, markerEntry{id: id, marker: marker}) m.markers = append(m.markers, markerEntry{id: id, marker: marker, handle: h})
return return h
} }
js := addMarkerJS(m.id, id, marker) js := addMarkerJS(m.id, id, marker, h.events)
m.ctx.ExecScript(js) m.ctx.ExecScript(js)
return h
}
// OnClick returns a MapEvent that fires when this marker is clicked.
func (h *MarkerHandle) OnClick() *MapEvent {
return h.on("click")
}
// OnDragStart returns a MapEvent that fires when dragging starts.
func (h *MarkerHandle) OnDragStart() *MapEvent {
return h.on("dragstart")
}
// OnDrag returns a MapEvent that fires during dragging.
func (h *MarkerHandle) OnDrag() *MapEvent {
return h.on("drag")
}
// OnDragEnd returns a MapEvent that fires when dragging ends.
func (h *MarkerHandle) OnDragEnd() *MapEvent {
return h.on("dragend")
}
func (h *MarkerHandle) on(event string) *MapEvent {
sig := h.m.ctx.Signal("")
ev := &MapEvent{signal: sig}
h.events = append(h.events, markerEventEntry{event: event, signal: sig})
return ev
} }
// RemoveMarker removes a marker from the map. // RemoveMarker removes a marker from the map.
// Before render, it removes a previously accumulated marker. After render, it issues an ExecScript.
func (m *Map) RemoveMarker(id string) { func (m *Map) RemoveMarker(id string) {
if !m.rendered { if !m.rendered {
for i, me := range m.markers { for i, me := range m.markers {
@@ -278,17 +365,36 @@ func (m *Map) RemoveMarker(id string) {
// --- Popup methods --- // --- Popup methods ---
// ShowPopup shows a standalone popup on the map. // ShowPopup shows a standalone popup on the map.
func (m *Map) ShowPopup(id string, popup Popup) { // The returned PopupHandle can be used to subscribe to popup events.
func (m *Map) ShowPopup(id string, popup Popup) *PopupHandle {
ph := &PopupHandle{popupID: id, m: m}
if !m.rendered { if !m.rendered {
m.popups = append(m.popups, popupEntry{id: id, popup: popup}) m.popups = append(m.popups, popupEntry{id: id, popup: popup, handle: ph})
return return ph
} }
js := showPopupJS(m.id, id, popup) js := showPopupJS(m.id, id, popup, ph.events)
m.ctx.ExecScript(js) m.ctx.ExecScript(js)
return ph
}
// OnOpen returns a MapEvent that fires when the popup opens.
func (ph *PopupHandle) OnOpen() *MapEvent {
return ph.on("open")
}
// OnClose returns a MapEvent that fires when the popup closes.
func (ph *PopupHandle) OnClose() *MapEvent {
return ph.on("close")
}
func (ph *PopupHandle) on(event string) *MapEvent {
sig := ph.m.ctx.Signal("")
ev := &MapEvent{signal: sig}
ph.events = append(ph.events, popupEventEntry{event: event, signal: sig})
return ev
} }
// ClosePopup closes a standalone popup on the map. // ClosePopup closes a standalone popup on the map.
// Before render, it removes a previously accumulated popup. After render, it issues an ExecScript.
func (m *Map) ClosePopup(id string) { func (m *Map) ClosePopup(id string) {
if !m.rendered { if !m.rendered {
for i, pe := range m.popups { for i, pe := range m.popups {
@@ -302,6 +408,64 @@ func (m *Map) ClosePopup(id string) {
m.exec(closePopupJS(id)) 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 --- // --- Escape hatch ---
// Exec runs arbitrary JS with the map available as `m`. // Exec runs arbitrary JS with the map available as `m`.
@@ -314,6 +478,30 @@ func (m *Map) exec(body string) {
m.ctx.ExecScript(guard(m.id, body)) 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 { func parseFloat(s string) float64 {
f, _ := strconv.ParseFloat(s, 64) f, _ := strconv.ParseFloat(s, 64)
return f return f

View File

@@ -1,6 +1,10 @@
package maplibre package maplibre
import "encoding/json" import (
"encoding/json"
"github.com/ryanhamamura/via"
)
// LngLat represents a geographic coordinate. // LngLat represents a geographic coordinate.
type LngLat struct { type LngLat struct {
@@ -8,6 +12,20 @@ type LngLat struct {
Lat 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. // Options configures the initial map state.
type Options struct { type Options struct {
// Style is the map style URL (required). // Style is the map style URL (required).
@@ -23,6 +41,30 @@ type Options struct {
// CSS dimensions for the map container. Defaults: "100%", "400px". // CSS dimensions for the map container. Defaults: "100%", "400px".
Width string Width string
Height 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. // GeoJSONSource provides inline GeoJSON data to MapLibre.
@@ -31,7 +73,7 @@ type GeoJSONSource struct {
Data any Data any
} }
func (s GeoJSONSource) toJS() string { func (s GeoJSONSource) sourceJS() string {
data, _ := json.Marshal(s.Data) data, _ := json.Marshal(s.Data)
return `{"type":"geojson","data":` + string(data) + `}` return `{"type":"geojson","data":` + string(data) + `}`
} }
@@ -42,7 +84,7 @@ type VectorSource struct {
Tiles []string Tiles []string
} }
func (s VectorSource) toJS() string { func (s VectorSource) sourceJS() string {
obj := map[string]any{"type": "vector"} obj := map[string]any{"type": "vector"}
if s.URL != "" { if s.URL != "" {
obj["url"] = s.URL obj["url"] = s.URL
@@ -61,7 +103,7 @@ type RasterSource struct {
TileSize int TileSize int
} }
func (s RasterSource) toJS() string { func (s RasterSource) sourceJS() string {
obj := map[string]any{"type": "raster"} obj := map[string]any{"type": "raster"}
if s.URL != "" { if s.URL != "" {
obj["url"] = s.URL obj["url"] = s.URL
@@ -76,21 +118,135 @@ func (s RasterSource) toJS() string {
return string(b) return string(b)
} }
// sourceJSON converts a source value to its JS object literal string. // RawSource is an escape hatch that passes an arbitrary JSON-marshalable
func sourceJSON(src any) string { // value directly as a MapLibre source definition.
switch s := src.(type) { type RawSource struct {
case GeoJSONSource: Value any
return s.toJS()
case VectorSource:
return s.toJS()
case RasterSource:
return s.toJS()
default:
b, _ := json.Marshal(src)
return string(b)
}
} }
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. // Layer describes a MapLibre style layer.
type Layer struct { type Layer struct {
ID string ID string
@@ -137,12 +293,52 @@ func (l Layer) toJS() string {
return string(b) return string(b)
} }
// --- Marker ---
// Marker describes a map marker. // Marker describes a map marker.
type Marker struct { type Marker struct {
LngLat LngLat LngLat LngLat // static position (used when signals are nil)
Color string Color string
Draggable bool Draggable bool
Popup *Popup Popup *Popup
// Element is raw HTML/SVG used as a custom marker instead of the
// default pin. When set, Color is ignored.
// Do not pass untrusted user input without sanitizing it first.
Element string
// Anchor controls which part of the element sits at the coordinate.
// Values: "center" (default for custom elements), "bottom" (default
// for the pin), "top", "left", "right", "top-left", etc.
Anchor string
// Rotation is clockwise degrees. Useful for directional icons (ships, vehicles).
// Ignored when RotationSignal is set.
Rotation float64
// 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
// RotationSignal drives marker rotation reactively. When set, Rotation is ignored.
RotationSignal *via.Signal
// Offset is a pixel offset from the anchor point as [x, y].
Offset [2]float64
// Scale is a scaling factor for the default marker pin (0 = omit, MapLibre default 1).
Scale float64
// Opacity is the marker opacity 01 (0 = omit, MapLibre default 1).
Opacity float64
// OpacityWhenCovered is the marker opacity when behind 3D terrain (0 = omit).
OpacityWhenCovered float64
// ClassName is a CSS class added to the marker container element.
ClassName string
} }
// Popup describes a map popup. // Popup describes a map popup.
@@ -154,22 +350,85 @@ type Popup struct {
LngLat LngLat LngLat LngLat
HideCloseButton bool // true removes the close button (MapLibre shows it by default) HideCloseButton bool // true removes the close button (MapLibre shows it by default)
MaxWidth string MaxWidth string
// CloseOnClick controls whether the popup closes on map click.
// nil = MapLibre default (true).
CloseOnClick *bool
// CloseOnMove controls whether the popup closes on map move.
// nil = MapLibre default (false).
CloseOnMove *bool
// Anchor forces the popup anchor position ("top", "bottom", "left", "right", etc.).
Anchor string
// Offset is a pixel offset from the anchor as [x, y].
Offset [2]float64
// ClassName is a CSS class added to the popup container.
ClassName string
} }
// sourceEntry pairs a source ID with its JS representation for pre-render accumulation. // --- 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 { type sourceEntry struct {
id string id string
js string js string
} }
// markerEntry pairs a marker ID with its definition for pre-render accumulation. // MarkerHandle is returned by AddMarker and allows subscribing to marker events.
type MarkerHandle struct {
markerID string
m *Map
events []markerEventEntry
}
type markerEventEntry struct {
event string
signal *via.Signal
}
type markerEntry struct { type markerEntry struct {
id string id string
marker Marker marker Marker
handle *MarkerHandle
}
// PopupHandle is returned by ShowPopup and allows subscribing to popup events.
type PopupHandle struct {
popupID string
m *Map
events []popupEventEntry
}
type popupEventEntry struct {
event string
signal *via.Signal
} }
// popupEntry pairs a popup ID with its definition for pre-render accumulation.
type popupEntry struct { type popupEntry struct {
id string id string
popup Popup popup Popup
handle *PopupHandle
}
type eventEntry struct {
event string
layerID string
signal *via.Signal
}
type controlEntry struct {
id string
ctrl Control
} }

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,36 +48,35 @@ 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
} }
return 0 return 0
} }

View File

@@ -1,7 +1,6 @@
package via package via
import ( import (
// "net/http/httptest"
"testing" "testing"
"github.com/ryanhamamura/via/h" "github.com/ryanhamamura/via/h"
@@ -27,7 +26,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 +56,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 })

158
via.go
View File

@@ -58,7 +58,6 @@ type V struct {
datastarPath string datastarPath string
datastarContent []byte datastarContent []byte
datastarOnce sync.Once datastarOnce sync.Once
reaperStop chan struct{}
middleware []Middleware middleware []Middleware
layout func(func() h.H) h.H layout func(func() h.H) h.H
} }
@@ -139,12 +138,6 @@ func (v *V) Config(cfg Options) {
v.defaultNATS = nil v.defaultNATS = nil
v.pubsub = cfg.PubSub v.pubsub = cfg.PubSub
} }
if cfg.ContextSuspendAfter != 0 {
v.cfg.ContextSuspendAfter = cfg.ContextSuspendAfter
}
if cfg.ContextTTL != 0 {
v.cfg.ContextTTL = cfg.ContextTTL
}
if cfg.Streams != nil { if cfg.Streams != nil {
v.cfg.Streams = cfg.Streams v.cfg.Streams = cfg.Streams
} }
@@ -292,75 +285,6 @@ func (v *V) getCtx(id string) (*Context, error) {
return nil, fmt.Errorf("ctx '%s' not found", id) return nil, fmt.Errorf("ctx '%s' not found", id)
} }
func (v *V) startReaper() {
ttl := v.cfg.ContextTTL
if ttl < 0 {
return
}
if ttl == 0 {
ttl = time.Hour
}
suspendAfter := v.cfg.ContextSuspendAfter
if suspendAfter == 0 {
suspendAfter = 15 * time.Minute
}
if suspendAfter > ttl {
suspendAfter = ttl
}
interval := suspendAfter / 3
if interval < 5*time.Second {
interval = 5 * time.Second
}
v.reaperStop = make(chan struct{})
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-v.reaperStop:
return
case <-ticker.C:
v.reapOrphanedContexts(suspendAfter, ttl)
}
}
}()
}
func (v *V) reapOrphanedContexts(suspendAfter, ttl time.Duration) {
now := time.Now()
v.contextRegistryMutex.RLock()
var toSuspend, toReap []*Context
for _, c := range v.contextRegistry {
if c.sseConnected.Load() {
continue
}
// Use the most recent liveness signal
lastAlive := c.createdAt
if dc := c.sseDisconnectedAt.Load(); dc != nil && dc.After(lastAlive) {
lastAlive = *dc
}
if seen := c.lastSeenAt.Load(); seen != nil && seen.After(lastAlive) {
lastAlive = *seen
}
silentFor := now.Sub(lastAlive)
if silentFor > ttl {
toReap = append(toReap, c)
} else if silentFor > suspendAfter && !c.suspended.Load() {
toSuspend = append(toSuspend, c)
}
}
v.contextRegistryMutex.RUnlock()
for _, c := range toSuspend {
v.logInfo(c, "suspending context (no SSE connection after %s)", suspendAfter)
c.suspend()
}
for _, c := range toReap {
v.logInfo(c, "reaping orphaned context (no SSE connection after %s)", ttl)
v.cleanupCtx(c)
}
}
// Start starts the Via HTTP server and blocks until a SIGINT or SIGTERM // Start starts the Via HTTP server and blocks until a SIGINT or SIGTERM
// signal is received, then performs a graceful shutdown. // signal is received, then performs a graceful shutdown.
func (v *V) Start() { func (v *V) Start() {
@@ -389,8 +313,6 @@ func (v *V) Start() {
Handler: handler, Handler: handler,
} }
v.startReaper()
errCh := make(chan error, 1) errCh := make(chan error, 1)
go func() { go func() {
errCh <- v.server.ListenAndServe() errCh <- v.server.ListenAndServe()
@@ -417,9 +339,6 @@ func (v *V) Start() {
// Shutdown gracefully shuts down the server and all contexts. // Shutdown gracefully shuts down the server and all contexts.
// Safe for programmatic or test use. // Safe for programmatic or test use.
func (v *V) Shutdown() { func (v *V) Shutdown() {
if v.reaperStop != nil {
close(v.reaperStop)
}
v.logInfo(nil, "draining all contexts") v.logInfo(nil, "draining all contexts")
v.drainAllContexts() v.drainAllContexts()
@@ -520,36 +439,39 @@ func (v *V) ensureDatastarHandler() {
}) })
} }
func loadDevModeMap(path string) map[string]string {
m := make(map[string]string)
file, err := os.Open(path)
if err != nil {
return m
}
defer file.Close()
json.NewDecoder(file).Decode(&m)
return m
}
func saveDevModeMap(path string, m map[string]string) error {
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
return json.NewEncoder(file).Encode(m)
}
func (v *V) devModePersist(c *Context) { func (v *V) devModePersist(c *Context) {
p := filepath.Join(".via", "devmode", "ctx.json") p := filepath.Join(".via", "devmode", "ctx.json")
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
v.logFatal("failed to create directory for devmode files: %v", err) v.logFatal("failed to create directory for devmode files: %v", err)
} }
// load persisted list from file, or empty list if file not found ctxRegMap := loadDevModeMap(p)
file, err := os.Open(p)
ctxRegMap := make(map[string]string)
if err == nil {
json.NewDecoder(file).Decode(&ctxRegMap)
}
file.Close()
// add ctx to persisted list
if _, ok := ctxRegMap[c.id]; !ok { if _, ok := ctxRegMap[c.id]; !ok {
ctxRegMap[c.id] = c.route ctxRegMap[c.id] = c.route
} }
// write persisted list to file if err := saveDevModeMap(p, ctxRegMap); err != nil {
file, err = os.Create(p) v.logErr(c, "devmode failed to persist ctx: %v", err)
if err != nil {
v.logErr(c, "devmode failed to percist ctx: %v", err)
}
defer file.Close()
encoder := json.NewEncoder(file)
if err := encoder.Encode(ctxRegMap); err != nil {
v.logErr(c, "devmode failed to persist ctx")
} }
v.logDebug(c, "devmode persisted ctx to file") v.logDebug(c, "devmode persisted ctx to file")
} }
@@ -557,27 +479,11 @@ func (v *V) devModePersist(c *Context) {
func (v *V) devModeRemovePersisted(c *Context) { func (v *V) devModeRemovePersisted(c *Context) {
p := filepath.Join(".via", "devmode", "ctx.json") p := filepath.Join(".via", "devmode", "ctx.json")
// load persisted list from file, or empty list if file not found ctxRegMap := loadDevModeMap(p)
file, err := os.Open(p)
ctxRegMap := make(map[string]string)
if err == nil {
json.NewDecoder(file).Decode(&ctxRegMap)
}
file.Close()
delete(ctxRegMap, c.id) delete(ctxRegMap, c.id)
// write persisted list to file if err := saveDevModeMap(p, ctxRegMap); err != nil {
file, err = os.Create(p) v.logErr(c, "devmode failed to remove persisted ctx: %v", err)
if err != nil {
v.logErr(c, "devmode failed to remove percisted ctx: %v", err)
}
defer file.Close()
encoder := json.NewEncoder(file)
if err := encoder.Encode(ctxRegMap); err != nil {
v.logErr(c, "devmode failed to remove persisted ctx")
} }
v.logDebug(c, "devmode removed persisted ctx from file") v.logDebug(c, "devmode removed persisted ctx from file")
} }
@@ -667,8 +573,6 @@ 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))))
@@ -690,16 +594,6 @@ func New() *V {
c.sseDisconnectedAt.Store(nil) c.sseDisconnectedAt.Store(nil)
v.logDebug(c, "SSE connection established") v.logDebug(c, "SSE connection established")
if c.suspended.Load() {
c.navMu.Lock()
c.suspended.Store(false)
if initFn := v.pageRegistry[c.route]; initFn != nil {
v.logInfo(c, "resuming suspended context")
initFn(c)
}
c.navMu.Unlock()
}
go c.Sync() go c.Sync()
keepalive := time.NewTicker(30 * time.Second) keepalive := time.NewTicker(30 * time.Second)

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")
@@ -400,95 +400,6 @@ func TestPage_PanicsOnNoView(t *testing.T) {
}) })
} }
func TestReaperCleansOrphanedContexts(t *testing.T) {
v := New()
c := newContext("orphan-1", "/", v)
c.createdAt = time.Now().Add(-time.Minute) // created 1 min ago
v.registerCtx(c)
_, err := v.getCtx("orphan-1")
assert.NoError(t, err)
v.reapOrphanedContexts(5*time.Second, 10*time.Second)
_, err = v.getCtx("orphan-1")
assert.Error(t, err, "orphaned context should have been reaped")
}
func TestReaperSuspendsContext(t *testing.T) {
v := New()
c := newContext("suspend-1", "/", v)
c.createdAt = time.Now().Add(-30 * time.Minute)
dc := time.Now().Add(-20 * time.Minute)
c.sseDisconnectedAt.Store(&dc)
v.registerCtx(c)
v.reapOrphanedContexts(15*time.Minute, time.Hour)
got, err := v.getCtx("suspend-1")
assert.NoError(t, err, "suspended context should still be in registry")
assert.True(t, got.suspended.Load(), "context should be marked suspended")
}
func TestReaperReapsAfterTTL(t *testing.T) {
v := New()
c := newContext("reap-1", "/", v)
c.createdAt = time.Now().Add(-3 * time.Hour)
dc := time.Now().Add(-2 * time.Hour)
c.sseDisconnectedAt.Store(&dc)
c.suspended.Store(true)
v.registerCtx(c)
v.reapOrphanedContexts(15*time.Minute, time.Hour)
_, err := v.getCtx("reap-1")
assert.Error(t, err, "context past TTL should have been reaped")
}
func TestReaperIgnoresAlreadySuspended(t *testing.T) {
v := New()
c := newContext("already-sus-1", "/", v)
c.createdAt = time.Now().Add(-30 * time.Minute)
dc := time.Now().Add(-20 * time.Minute)
c.sseDisconnectedAt.Store(&dc)
c.suspended.Store(true)
// give it a fresh pageStopChan so we can verify it's not re-closed
c.pageStopChan = make(chan struct{})
v.registerCtx(c)
v.reapOrphanedContexts(15*time.Minute, time.Hour)
got, err := v.getCtx("already-sus-1")
assert.NoError(t, err, "already-suspended context within TTL should survive")
assert.True(t, got.suspended.Load())
// pageStopChan should still be open (not re-suspended)
select {
case <-got.pageStopChan:
t.Fatal("pageStopChan was closed — context was re-suspended")
default:
}
}
func TestReaperIgnoresConnectedContexts(t *testing.T) {
v := New()
c := newContext("connected-1", "/", v)
c.createdAt = time.Now().Add(-time.Minute)
c.sseConnected.Store(true)
v.registerCtx(c)
v.reapOrphanedContexts(5*time.Second, 10*time.Second)
_, err := v.getCtx("connected-1")
assert.NoError(t, err, "connected context should survive reaping")
}
func TestReaperDisabledWithNegativeTTL(t *testing.T) {
v := New()
v.cfg.ContextTTL = -1
v.startReaper()
assert.Nil(t, v.reaperStop, "reaper should not start with negative TTL")
}
func TestCleanupCtxIdempotent(t *testing.T) { func TestCleanupCtxIdempotent(t *testing.T) {
v := New() v := New()
c := newContext("idempotent-1", "/", v) c := newContext("idempotent-1", "/", v)
@@ -503,74 +414,6 @@ 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