Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cbc5022e0d | |||
| 74b32800f9 | |||
| cb13839157 | |||
| f833498b65 | |||
| 6064ddd856 | |||
| dc56261b58 | |||
| c0f4782f2b | |||
| 47dcab8fea | |||
|
|
e63ebd1401 | ||
|
|
b26ded951f | ||
|
|
8bb1b99ae9 | ||
|
|
0d8bf04446 | ||
|
|
742212fd20 | ||
|
|
60009124c9 | ||
|
|
42b21348cb | ||
|
|
58ad9a2699 | ||
|
|
f3a9c8036f |
14
.claude/commands/pr.md
Normal file
14
.claude/commands/pr.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
Create a PR on Gitea, wait for CI, and squash-merge it. Push code to both remotes.
|
||||||
|
|
||||||
|
1. If in a worktree (working directory contains `.claude/worktrees/`), you are already on a feature branch — do NOT create a new one. Otherwise, create a new branch from main with a descriptive name.
|
||||||
|
2. Stage and commit all changes with a clean, semantic commit message. No Claude attribution lines.
|
||||||
|
3. Fetch latest main and rebase: `git fetch gitea main && git rebase gitea/main`.
|
||||||
|
- If conflicts occur, abort the rebase (`git rebase --abort`), analyze the conflicting files, write a plan to resolve them, and present the plan to the user before proceeding.
|
||||||
|
4. Push the branch to both remotes: `git push -u gitea <branch> && git push origin <branch>` (use `--force-with-lease` if already pushed).
|
||||||
|
5. Create a Gitea PR: `tea pr create --head <branch> --base main`. Reference related issues with `#X`. Only use `Closes #X` if the PR fully resolves the issue.
|
||||||
|
6. Wait for CI to pass: poll Gitea CI status. If CI fails, report the failure and stop — do not merge.
|
||||||
|
7. Once CI passes, squash-merge on Gitea: `tea pr merge <index> --style squash` with a clean, semantic commit message including the PR number. No Claude attribution lines.
|
||||||
|
8. Update local main and push to both remotes. 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.
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
Create a new release for this project. Steps:
|
Create a new release on Gitea. Push tags to both remotes.
|
||||||
|
|
||||||
1. Fetch tags from all remotes so the version list is current.
|
## Pre-flight
|
||||||
2. Check for uncommitted changes. If any exist, commit them with a clean semantic commit message. No Claude attribution lines.
|
|
||||||
3. Review the commits since the last tag. Based on their content, recommend a semver bump:
|
1. **Worktree guard**: If the working directory is inside `.claude/worktrees/`, STOP and tell the user: "Releases must be created from a non-worktree session on main. Exit this worktree or start a new session, then run /release." Do not proceed.
|
||||||
|
2. Verify you are on `main`. If not, STOP.
|
||||||
|
3. Verify there are no uncommitted changes. If there are, STOP — they should go through a PR.
|
||||||
|
4. Run `git pull --ff-only` on main. Fetch tags from all remotes.
|
||||||
|
|
||||||
|
## Release
|
||||||
|
|
||||||
|
5. Review commits since the last tag. Recommend a semver bump:
|
||||||
- **major**: breaking/incompatible API changes
|
- **major**: breaking/incompatible API changes
|
||||||
- **minor**: new features, meaningful new behavior
|
- **minor**: new features, meaningful new behavior
|
||||||
- **patch**: bug fixes, docs, refactoring with no new features
|
- **patch**: bug fixes, docs, refactoring with no new features
|
||||||
Present the proposed version, the bump rationale, and the commit list. Wait for user approval before continuing.
|
Present the proposed version, bump rationale, and commit list. Wait for user approval.
|
||||||
4. Tag the new version and push the tag + commits to all remotes (origin, gitea, etc.).
|
6. Tag the new version. Push the tag to both remotes: `git push gitea <tag> && git push origin <tag>`.
|
||||||
5. Generate release notes from the commits since the last tag, grouped by type (features, fixes, docs/refactoring).
|
7. Generate release notes grouped by type (features, fixes, chores).
|
||||||
6. Create a GitHub release using `gh release create`.
|
8. Create a Gitea release with `tea releases create` using the notes.
|
||||||
7. Create a Gitea release using `tea releases create` with the same notes.
|
9. Report the release URL and confirm all remotes are up to date.
|
||||||
8. Report both release URLs and confirm all remotes are up to date.
|
|
||||||
|
|||||||
@@ -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
21
.gitignore
vendored
@@ -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
20
CLAUDE.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
10
ci-check.sh
10
ci-check.sh
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
@@ -53,19 +51,14 @@ type Options struct {
|
|||||||
// Defaults to "/_datastar.js" if empty.
|
// Defaults to "/_datastar.js" if empty.
|
||||||
DatastarPath string
|
DatastarPath string
|
||||||
|
|
||||||
// PubSub enables publish/subscribe messaging. Use vianats.New() for an
|
// PubSub enables publish/subscribe messaging. When nil, an embedded NATS
|
||||||
// embedded NATS backend, or supply any PubSub implementation.
|
// server starts automatically in Start(). Supply any PubSub implementation
|
||||||
|
// to replace it.
|
||||||
PubSub PubSub
|
PubSub PubSub
|
||||||
|
|
||||||
// ContextSuspendAfter is the time a context may be disconnected before
|
// Streams declares JetStream streams to create when Start() initializes
|
||||||
// the reaper suspends it (frees page resources but keeps the context
|
// the embedded NATS server. Ignored when a custom PubSub is configured.
|
||||||
// shell for seamless re-init on reconnect). Default: 15m.
|
Streams []StreamConfig
|
||||||
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).
|
||||||
|
|||||||
23
context.go
23
context.go
@@ -42,7 +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]
|
||||||
suspended atomic.Bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// View defines the UI rendered by this context.
|
// View defines the UI rendered by this context.
|
||||||
@@ -174,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),
|
||||||
@@ -190,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,
|
||||||
@@ -253,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
|
||||||
}
|
}
|
||||||
@@ -283,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()
|
||||||
@@ -442,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.
|
||||||
@@ -593,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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Infrastructure for multi-user real-time communication and persistent state.
|
|||||||
|
|
||||||
## PubSub
|
## PubSub
|
||||||
|
|
||||||
Via includes an embedded NATS server that starts automatically with `via.New()`. No external services required — pub/sub works out of the box.
|
Via includes an embedded NATS server that starts automatically with `v.Start()`. No external services required — pub/sub works out of the box.
|
||||||
|
|
||||||
### Interface
|
### Interface
|
||||||
|
|
||||||
@@ -73,14 +73,18 @@ This disables the embedded NATS server. The `NATSConn()` and `JetStream()` acces
|
|||||||
|
|
||||||
NATS JetStream provides persistent, replayable message streams. Useful for chat history, event logs, or any scenario where new subscribers need to catch up on past messages.
|
NATS JetStream provides persistent, replayable message streams. Useful for chat history, event logs, or any scenario where new subscribers need to catch up on past messages.
|
||||||
|
|
||||||
### Ensure a stream exists
|
### Declaring streams
|
||||||
|
|
||||||
|
The recommended approach is to declare streams in `Options.Streams`. They are created automatically when `v.Start()` initializes the embedded NATS server:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
err := via.EnsureStream(v, via.StreamConfig{
|
v.Config(via.Options{
|
||||||
|
Streams: []via.StreamConfig{{
|
||||||
Name: "CHAT",
|
Name: "CHAT",
|
||||||
Subjects: []string{"chat.>"},
|
Subjects: []string{"chat.>"},
|
||||||
MaxMsgs: 1000,
|
MaxMsgs: 1000,
|
||||||
MaxAge: 24 * time.Hour,
|
MaxAge: 24 * time.Hour,
|
||||||
|
}},
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -91,7 +95,16 @@ err := via.EnsureStream(v, via.StreamConfig{
|
|||||||
| `MaxMsgs` | Maximum number of messages to retain |
|
| `MaxMsgs` | Maximum number of messages to retain |
|
||||||
| `MaxAge` | Maximum age before messages are discarded |
|
| `MaxAge` | Maximum age before messages are discarded |
|
||||||
|
|
||||||
Call `EnsureStream` during app initialization, before `v.Start()`.
|
For dynamic stream creation after startup, `EnsureStream` is also available:
|
||||||
|
|
||||||
|
```go
|
||||||
|
err := via.EnsureStream(v, via.StreamConfig{
|
||||||
|
Name: "EVENTS",
|
||||||
|
Subjects: []string{"events.>"},
|
||||||
|
MaxMsgs: 500,
|
||||||
|
MaxAge: 12 * time.Hour,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
### Replay history
|
### Replay history
|
||||||
|
|
||||||
|
|||||||
@@ -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"`) |
|
||||||
|
|||||||
4
field.go
4
field.go
@@ -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
2
h/h.go
@@ -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
|
||||||
|
|
||||||
|
|||||||
52
internal/examples/effectspike/main.go
Normal file
52
internal/examples/effectspike/main.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
213
internal/examples/maplibre/main.go
Normal file
213
internal/examples/maplibre/main.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ryanhamamura/via"
|
||||||
|
"github.com/ryanhamamura/via/h"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
v := via.New()
|
||||||
|
v.Config(via.Options{
|
||||||
|
DocumentTitle: "MapLibre GL Example",
|
||||||
|
ServerAddress: ":7331",
|
||||||
|
DevMode: true,
|
||||||
|
Plugins: []via.Plugin{maplibre.Plugin},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
|
||||||
|
v.Page("/", func(c *via.Context) {
|
||||||
|
m := maplibre.New(c, maplibre.Options{
|
||||||
|
Style: "https://demotiles.maplibre.org/style.json",
|
||||||
|
Center: maplibre.LngLat{Lng: -122.4194, Lat: 37.7749},
|
||||||
|
Zoom: 10,
|
||||||
|
Height: "500px",
|
||||||
|
})
|
||||||
|
|
||||||
|
m.AddControl("nav", maplibre.NavigationControl{})
|
||||||
|
m.AddControl("scale", maplibre.ScaleControl{Unit: "metric"})
|
||||||
|
|
||||||
|
// Static markers with popups
|
||||||
|
m.AddMarker("sf", maplibre.Marker{
|
||||||
|
LngLat: maplibre.LngLat{Lng: -122.4194, Lat: 37.7749},
|
||||||
|
Color: "#e74c3c",
|
||||||
|
Popup: &maplibre.Popup{
|
||||||
|
Content: "<strong>San Francisco</strong><p>The Golden City</p>",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
m.AddMarker("oak", maplibre.Marker{
|
||||||
|
LngLat: maplibre.LngLat{Lng: -122.2711, Lat: 37.8044},
|
||||||
|
Color: "#2ecc71",
|
||||||
|
Popup: &maplibre.Popup{
|
||||||
|
Content: "<strong>Oakland</strong>",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Purple 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,
|
||||||
|
Color: "#9b59b6",
|
||||||
|
})
|
||||||
|
|
||||||
|
c.OnInterval(time.Second, func() {
|
||||||
|
vehicle.mu.RLock()
|
||||||
|
lng, lat := vehicle.lng, vehicle.lat
|
||||||
|
vehicle.mu.RUnlock()
|
||||||
|
vehicleLng.SetValue(lng)
|
||||||
|
vehicleLat.SetValue(lat)
|
||||||
|
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
|
||||||
|
m.AddSource("park", maplibre.GeoJSONSource{
|
||||||
|
Data: map[string]any{
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": map[string]any{
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": []any{[]any{
|
||||||
|
[]float64{-122.4547, 37.7654},
|
||||||
|
[]float64{-122.4547, 37.7754},
|
||||||
|
[]float64{-122.4387, 37.7754},
|
||||||
|
[]float64{-122.4387, 37.7654},
|
||||||
|
[]float64{-122.4547, 37.7654},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
"properties": map[string]any{
|
||||||
|
"name": "Golden Gate Park",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
m.AddLayer(maplibre.Layer{
|
||||||
|
ID: "park-fill",
|
||||||
|
Type: "fill",
|
||||||
|
Source: "park",
|
||||||
|
Paint: map[string]any{
|
||||||
|
"fill-color": "#2ecc71",
|
||||||
|
"fill-opacity": 0.3,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// FlyTo actions
|
||||||
|
zoom14 := 14.0
|
||||||
|
flyToSF := c.Action(func() {
|
||||||
|
m.FlyTo(maplibre.CameraOptions{
|
||||||
|
Center: &maplibre.LngLat{Lng: -122.4194, Lat: 37.7749},
|
||||||
|
Zoom: &zoom14,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
flyToOak := c.Action(func() {
|
||||||
|
m.FlyTo(maplibre.CameraOptions{
|
||||||
|
Center: &maplibre.LngLat{Lng: -122.2711, Lat: 37.8044},
|
||||||
|
Zoom: &zoom14,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
c.View(func() h.H {
|
||||||
|
return h.Div(
|
||||||
|
h.Div(
|
||||||
|
h.Attr("style", "max-width:960px;margin:0 auto;padding:1rem;font-family:sans-serif"),
|
||||||
|
h.H1(h.Text("MapLibre GL Example")),
|
||||||
|
m.Element(
|
||||||
|
click.Input(handleClick.OnInput()),
|
||||||
|
h.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 Oakland"), flyToOak.OnClick()),
|
||||||
|
),
|
||||||
|
h.Div(h.Attr("style", "margin-top:0.5rem;font-size:0.9rem"),
|
||||||
|
h.P(h.Text("Zoom: "), m.Zoom.Text()),
|
||||||
|
h.P(h.Text("Center: "), m.CenterLng.Text(), h.Text(", "), m.CenterLat.Text()),
|
||||||
|
h.P(h.Text("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()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
v.Start()
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A chatroom built with Via and an **embedded NATS server**, demonstrating pub/sub messaging as an alternative to the custom `Rooms` implementation in `../chatroom`.
|
A chatroom built with Via and an **embedded NATS server**, demonstrating pub/sub messaging as an alternative to the custom `Rooms` implementation in `../chatroom`.
|
||||||
|
|
||||||
Uses `delaneyj/toolbelt/embeddednats` to run NATS inside the same binary - no external server required.
|
Via includes an embedded NATS server that starts automatically — no external server required.
|
||||||
|
|
||||||
## Key Differences from Original Chatroom
|
## Key Differences from Original Chatroom
|
||||||
|
|
||||||
@@ -25,21 +25,6 @@ That's it. No separate NATS server needed.
|
|||||||
|
|
||||||
Open multiple browser tabs at http://localhost:7331 to see messages broadcast across all clients.
|
Open multiple browser tabs at http://localhost:7331 to see messages broadcast across all clients.
|
||||||
|
|
||||||
## How Embedded NATS Works
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Start embedded NATS server (JetStream enabled by default)
|
|
||||||
ns, err := embeddednats.New(ctx,
|
|
||||||
embeddednats.WithDirectory("./data/nats"),
|
|
||||||
)
|
|
||||||
ns.WaitForServer()
|
|
||||||
|
|
||||||
// Get client connection to embedded server
|
|
||||||
nc, err := ns.Client()
|
|
||||||
```
|
|
||||||
|
|
||||||
Data is persisted to `./data/nats/` for JetStream durability.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -65,14 +50,16 @@ Data is persisted to `./data/nats/` for JetStream durability.
|
|||||||
|
|
||||||
## JetStream Durability
|
## JetStream Durability
|
||||||
|
|
||||||
Messages persist to disk via JetStream:
|
Messages persist to disk via JetStream. Streams are declared in `Options.Streams` and created automatically when `v.Start()` initializes the embedded NATS server:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
js.AddStream(&nats.StreamConfig{
|
v.Config(via.Options{
|
||||||
|
Streams: []via.StreamConfig{{
|
||||||
Name: "CHAT",
|
Name: "CHAT",
|
||||||
Subjects: []string{"chat.>"},
|
Subjects: []string{"chat.>"},
|
||||||
MaxMsgs: 1000, // Keep last 1000 messages
|
MaxMsgs: 1000,
|
||||||
MaxAge: 24 * time.Hour,
|
MaxAge: 24 * time.Hour,
|
||||||
|
}},
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -87,23 +74,6 @@ Stop and restart the app - chat history survives.
|
|||||||
- Manual join/leave channels
|
- Manual join/leave channels
|
||||||
|
|
||||||
**This example - ~60 lines of NATS integration:**
|
**This example - ~60 lines of NATS integration:**
|
||||||
- `embeddednats.New()` starts the server
|
- `via.Subscribe(c, subject, handler)` for receiving
|
||||||
- `nc.Subscribe(subject, handler)` for receiving
|
- `via.Publish(c, subject, data)` for sending
|
||||||
- `nc.Publish(subject, data)` for sending
|
- Streams declared in `Options` — NATS handles delivery, no polling
|
||||||
- NATS handles delivery, no polling
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
If this pattern proves useful, it could be promoted to a Via plugin:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Hypothetical future API
|
|
||||||
v.Config(via.WithEmbeddedNATS("./data/nats"))
|
|
||||||
|
|
||||||
// In page init
|
|
||||||
c.Subscribe("events.user.*", func(data []byte) {
|
|
||||||
c.Sync()
|
|
||||||
})
|
|
||||||
|
|
||||||
c.Publish("events.user.login", userData)
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -21,17 +21,13 @@ func main() {
|
|||||||
DocumentTitle: "NATS Chat",
|
DocumentTitle: "NATS Chat",
|
||||||
LogLevel: via.LogLevelInfo,
|
LogLevel: via.LogLevelInfo,
|
||||||
ServerAddress: ":7331",
|
ServerAddress: ":7331",
|
||||||
})
|
Streams: []via.StreamConfig{{
|
||||||
|
|
||||||
err := via.EnsureStream(v, via.StreamConfig{
|
|
||||||
Name: "CHAT",
|
Name: "CHAT",
|
||||||
Subjects: []string{"chat.>"},
|
Subjects: []string{"chat.>"},
|
||||||
MaxMsgs: 1000,
|
MaxMsgs: 1000,
|
||||||
MaxAge: 24 * time.Hour,
|
MaxAge: 24 * time.Hour,
|
||||||
|
}},
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to ensure stream: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
v.AppendToHead(
|
v.AppendToHead(
|
||||||
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")),
|
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")),
|
||||||
@@ -76,6 +72,6 @@ func main() {
|
|||||||
protected := v.Group("", requireProfile)
|
protected := v.Group("", requireProfile)
|
||||||
protected.Page("/", ChatPage)
|
protected.Page("/", ChatPage)
|
||||||
|
|
||||||
log.Println("Starting NATS chatroom on :7331 (embedded NATS server)")
|
log.Println("Starting NATS chatroom on :7331")
|
||||||
v.Start()
|
v.Start()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ func ProfilePage(c *via.Context) {
|
|||||||
via.MaxLen(20, "Must be at most 20 characters"),
|
via.MaxLen(20, "Must be at most 20 characters"),
|
||||||
)
|
)
|
||||||
selectedEmoji := c.Signal(existingEmoji)
|
selectedEmoji := c.Signal(existingEmoji)
|
||||||
|
previewName := c.Computed(func() string {
|
||||||
|
if name := nameField.String(); name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return "Your Name"
|
||||||
|
})
|
||||||
|
|
||||||
saveToSession := func() bool {
|
saveToSession := func() bool {
|
||||||
if !c.ValidateAll() {
|
if !c.ValidateAll() {
|
||||||
@@ -68,18 +74,13 @@ func ProfilePage(c *via.Context) {
|
|||||||
h.Button(h.Text("Start Chatting"), saveAndChat.OnClick()),
|
h.Button(h.Text("Start Chatting"), saveAndChat.OnClick()),
|
||||||
)
|
)
|
||||||
|
|
||||||
previewName := nameField.String()
|
|
||||||
if previewName == "" {
|
|
||||||
previewName = "Your Name"
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.Div(h.Class("profile-page"),
|
return h.Div(h.Class("profile-page"),
|
||||||
h.H2(h.Text("Your Profile"), h.DataViewTransition("page-title")),
|
h.H2(h.Text("Your Profile"), h.DataViewTransition("page-title")),
|
||||||
|
|
||||||
// Live preview
|
// Live preview
|
||||||
h.Div(h.Class("profile-preview"),
|
h.Div(h.Class("profile-preview"),
|
||||||
h.Div(h.Class("avatar avatar-lg"), h.Text(selectedEmoji.String())),
|
h.Div(h.Class("avatar avatar-lg"), h.Text(selectedEmoji.String())),
|
||||||
h.Span(h.Class("preview-name"), h.Text(previewName)),
|
h.Span(h.Class("preview-name"), previewName.Text()),
|
||||||
),
|
),
|
||||||
|
|
||||||
h.Div(h.Class("profile-form"),
|
h.Div(h.Class("profile-form"),
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Binary file not shown.
@@ -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) {
|
||||||
|
|||||||
@@ -53,17 +53,13 @@ func main() {
|
|||||||
DocumentTitle: "Bookmarks",
|
DocumentTitle: "Bookmarks",
|
||||||
LogLevel: via.LogLevelInfo,
|
LogLevel: via.LogLevelInfo,
|
||||||
ServerAddress: ":7331",
|
ServerAddress: ":7331",
|
||||||
})
|
Streams: []via.StreamConfig{{
|
||||||
|
|
||||||
err := via.EnsureStream(v, via.StreamConfig{
|
|
||||||
Name: "BOOKMARKS",
|
Name: "BOOKMARKS",
|
||||||
Subjects: []string{"bookmarks.>"},
|
Subjects: []string{"bookmarks.>"},
|
||||||
MaxMsgs: 1000,
|
MaxMsgs: 1000,
|
||||||
MaxAge: 24 * time.Hour,
|
MaxAge: 24 * time.Hour,
|
||||||
|
}},
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to ensure stream: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
v.AppendToHead(
|
v.AppendToHead(
|
||||||
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css")),
|
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css")),
|
||||||
@@ -76,6 +72,12 @@ func main() {
|
|||||||
titleSignal := c.Signal("")
|
titleSignal := c.Signal("")
|
||||||
urlSignal := c.Signal("")
|
urlSignal := c.Signal("")
|
||||||
targetIDSignal := c.Signal("")
|
targetIDSignal := c.Signal("")
|
||||||
|
saveLabel := c.Computed(func() string {
|
||||||
|
if targetIDSignal.String() != "" {
|
||||||
|
return "Update Bookmark"
|
||||||
|
}
|
||||||
|
return "Add Bookmark"
|
||||||
|
})
|
||||||
|
|
||||||
via.Subscribe(c, "bookmarks.events", func(evt CRUDEvent) {
|
via.Subscribe(c, "bookmarks.events", func(evt CRUDEvent) {
|
||||||
if evt.UserID == userID {
|
if evt.UserID == userID {
|
||||||
@@ -205,11 +207,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
bookmarksMu.RUnlock()
|
bookmarksMu.RUnlock()
|
||||||
|
|
||||||
saveLabel := "Add Bookmark"
|
|
||||||
if isEditing {
|
|
||||||
saveLabel = "Update Bookmark"
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.Div(h.Class("min-h-screen bg-base-200"),
|
return h.Div(h.Class("min-h-screen bg-base-200"),
|
||||||
// Navbar
|
// Navbar
|
||||||
h.Div(h.Class("navbar bg-base-100 shadow-sm"),
|
h.Div(h.Class("navbar bg-base-100 shadow-sm"),
|
||||||
@@ -225,7 +222,7 @@ func main() {
|
|||||||
// Form card
|
// Form card
|
||||||
h.Div(h.Class("card bg-base-100 shadow"),
|
h.Div(h.Class("card bg-base-100 shadow"),
|
||||||
h.Div(h.Class("card-body"),
|
h.Div(h.Class("card-body"),
|
||||||
h.H2(h.Class("card-title"), h.Text(saveLabel)),
|
h.H2(h.Class("card-title"), saveLabel.Text()),
|
||||||
h.Div(h.Class("flex flex-col gap-2"),
|
h.Div(h.Class("flex flex-col gap-2"),
|
||||||
h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("Title"), titleSignal.Bind()),
|
h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("Title"), titleSignal.Bind()),
|
||||||
h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("https://example.com"), urlSignal.Bind()),
|
h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("https://example.com"), urlSignal.Bind()),
|
||||||
@@ -233,7 +230,7 @@ func main() {
|
|||||||
h.If(isEditing,
|
h.If(isEditing,
|
||||||
h.Button(h.Class("btn btn-ghost"), h.Text("Cancel"), cancelEdit.OnClick()),
|
h.Button(h.Class("btn btn-ghost"), h.Text("Cancel"), cancelEdit.OnClick()),
|
||||||
),
|
),
|
||||||
h.Button(h.Class("btn btn-primary"), h.Text(saveLabel), save.OnClick()),
|
h.Button(h.Class("btn btn-primary"), saveLabel.Text(), save.OnClick()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Binary file not shown.
418
maplibre/js.go
Normal file
418
maplibre/js.go
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
package maplibre
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// guard wraps JS code so it only runs when the map instance exists.
|
||||||
|
// The body can reference the map as `m`.
|
||||||
|
func guard(mapID, body string) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`(function(){var m=window.__via_maps&&window.__via_maps[%s];if(!m)return;%s})()`,
|
||||||
|
jsonStr(mapID), body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonStr JSON-encodes a string for safe embedding in JS.
|
||||||
|
func jsonStr(s string) string {
|
||||||
|
b, _ := json.Marshal(s)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonVal JSON-encodes an arbitrary value for safe embedding in JS.
|
||||||
|
func jsonVal(v any) string {
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initScript generates the idempotent map initialization JS.
|
||||||
|
func initScript(m *Map) string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
`(function(){if(window.__via_maps&&window.__via_maps[%[1]s])return;`,
|
||||||
|
jsonStr(m.id),
|
||||||
|
))
|
||||||
|
|
||||||
|
// Build constructor options object
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
`var opts={container:%s,style:%s,center:[%s,%s],zoom:%s`,
|
||||||
|
jsonStr("_vmap_"+m.id),
|
||||||
|
jsonStr(m.opts.Style),
|
||||||
|
formatFloat(m.opts.Center.Lng),
|
||||||
|
formatFloat(m.opts.Center.Lat),
|
||||||
|
formatFloat(m.opts.Zoom),
|
||||||
|
))
|
||||||
|
if m.opts.Bearing != 0 {
|
||||||
|
b.WriteString(fmt.Sprintf(`,bearing:%s`, formatFloat(m.opts.Bearing)))
|
||||||
|
}
|
||||||
|
if m.opts.Pitch != 0 {
|
||||||
|
b.WriteString(fmt.Sprintf(`,pitch:%s`, formatFloat(m.opts.Pitch)))
|
||||||
|
}
|
||||||
|
if m.opts.MinZoom != 0 {
|
||||||
|
b.WriteString(fmt.Sprintf(`,minZoom:%s`, formatFloat(m.opts.MinZoom)))
|
||||||
|
}
|
||||||
|
if m.opts.MaxZoom != 0 {
|
||||||
|
b.WriteString(fmt.Sprintf(`,maxZoom:%s`, formatFloat(m.opts.MaxZoom)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interaction toggles
|
||||||
|
writeBoolOpt := func(name string, val *bool) {
|
||||||
|
if val != nil {
|
||||||
|
if *val {
|
||||||
|
b.WriteString(fmt.Sprintf(`,%s:true`, name))
|
||||||
|
} else {
|
||||||
|
b.WriteString(fmt.Sprintf(`,%s:false`, name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeBoolOpt("scrollZoom", m.opts.ScrollZoom)
|
||||||
|
writeBoolOpt("boxZoom", m.opts.BoxZoom)
|
||||||
|
writeBoolOpt("dragRotate", m.opts.DragRotate)
|
||||||
|
writeBoolOpt("dragPan", m.opts.DragPan)
|
||||||
|
writeBoolOpt("keyboard", m.opts.Keyboard)
|
||||||
|
writeBoolOpt("doubleClickZoom", m.opts.DoubleClickZoom)
|
||||||
|
writeBoolOpt("touchZoomRotate", m.opts.TouchZoomRotate)
|
||||||
|
writeBoolOpt("touchPitch", m.opts.TouchPitch)
|
||||||
|
writeBoolOpt("renderWorldCopies", m.opts.RenderWorldCopies)
|
||||||
|
|
||||||
|
if m.opts.MaxBounds != nil {
|
||||||
|
b.WriteString(fmt.Sprintf(`,maxBounds:[[%s,%s],[%s,%s]]`,
|
||||||
|
formatFloat(m.opts.MaxBounds.SW.Lng), formatFloat(m.opts.MaxBounds.SW.Lat),
|
||||||
|
formatFloat(m.opts.MaxBounds.NE.Lng), formatFloat(m.opts.MaxBounds.NE.Lat)))
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(`};`)
|
||||||
|
|
||||||
|
// Merge Extra options
|
||||||
|
if len(m.opts.Extra) > 0 {
|
||||||
|
extra, _ := json.Marshal(m.opts.Extra)
|
||||||
|
b.WriteString(fmt.Sprintf(`Object.assign(opts,%s);`, string(extra)))
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(`var map=new maplibregl.Map(opts);`)
|
||||||
|
b.WriteString(`if(!window.__via_maps)window.__via_maps={};`)
|
||||||
|
b.WriteString(fmt.Sprintf(`window.__via_maps[%s]=map;`, jsonStr(m.id)))
|
||||||
|
b.WriteString(`map._via_markers={};map._via_popups={};map._via_controls={};`)
|
||||||
|
|
||||||
|
// Pre-render sources, layers, markers, popups, controls run on 'load'
|
||||||
|
hasLoad := len(m.sources) > 0 || len(m.layers) > 0 || len(m.markers) > 0 || len(m.popups) > 0 || len(m.controls) > 0
|
||||||
|
if hasLoad {
|
||||||
|
b.WriteString(`map.on('load',function(){`)
|
||||||
|
for _, src := range m.sources {
|
||||||
|
b.WriteString(fmt.Sprintf(`map.addSource(%s,%s);`, jsonStr(src.id), src.js))
|
||||||
|
}
|
||||||
|
for _, layer := range m.layers {
|
||||||
|
if layer.Before != "" {
|
||||||
|
b.WriteString(fmt.Sprintf(`map.addLayer(%s,%s);`, layer.toJS(), jsonStr(layer.Before)))
|
||||||
|
} else {
|
||||||
|
b.WriteString(fmt.Sprintf(`map.addLayer(%s);`, layer.toJS()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, me := range m.markers {
|
||||||
|
b.WriteString(markerBodyJS(m.id, me.id, me.marker))
|
||||||
|
}
|
||||||
|
for _, pe := range m.popups {
|
||||||
|
b.WriteString(popupBodyJS(pe.id, pe.popup))
|
||||||
|
}
|
||||||
|
for _, ce := range m.controls {
|
||||||
|
b.WriteString(controlBodyJS(ce.id, ce.ctrl))
|
||||||
|
}
|
||||||
|
b.WriteString(`});`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
for _, ev := range m.events {
|
||||||
|
b.WriteString(eventListenerJS(m.id, ev))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync viewport signals on moveend via hidden inputs
|
||||||
|
b.WriteString(fmt.Sprintf(`map.on('moveend',function(){`+
|
||||||
|
`var c=map.getCenter();`+
|
||||||
|
`var el=document.getElementById(%[1]s);if(!el)return;`+
|
||||||
|
`var inputs=el.querySelectorAll('input[data-bind]');`+
|
||||||
|
`inputs.forEach(function(inp){`+
|
||||||
|
`var sig=inp.getAttribute('data-bind');`+
|
||||||
|
`if(sig===%[2]s)inp.value=c.lng;`+
|
||||||
|
`else if(sig===%[3]s)inp.value=c.lat;`+
|
||||||
|
`else if(sig===%[4]s)inp.value=map.getZoom();`+
|
||||||
|
`else if(sig===%[5]s)inp.value=map.getBearing();`+
|
||||||
|
`else if(sig===%[6]s)inp.value=map.getPitch();`+
|
||||||
|
`else return;`+
|
||||||
|
`inp.dispatchEvent(new Event('input',{bubbles:true}));`+
|
||||||
|
`});`+
|
||||||
|
`});`,
|
||||||
|
jsonStr("_vwrap_"+m.id),
|
||||||
|
jsonStr(m.CenterLng.ID()),
|
||||||
|
jsonStr(m.CenterLat.ID()),
|
||||||
|
jsonStr(m.Zoom.ID()),
|
||||||
|
jsonStr(m.Bearing.ID()),
|
||||||
|
jsonStr(m.Pitch.ID()),
|
||||||
|
))
|
||||||
|
|
||||||
|
// ResizeObserver for auto-resize
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
`var ro=new ResizeObserver(function(){map.resize();});`+
|
||||||
|
`ro.observe(document.getElementById(%s));`,
|
||||||
|
jsonStr("_vmap_"+m.id),
|
||||||
|
))
|
||||||
|
|
||||||
|
// MutationObserver to clean up on DOM removal (SPA nav)
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
`var container=document.getElementById(%[1]s);`+
|
||||||
|
`if(container){var mo=new MutationObserver(function(){`+
|
||||||
|
`if(!document.contains(container)){`+
|
||||||
|
`mo.disconnect();ro.disconnect();map.remove();`+
|
||||||
|
`delete window.__via_maps[%[2]s];`+
|
||||||
|
`}});`+
|
||||||
|
`mo.observe(document.body,{childList:true,subtree:true});}`,
|
||||||
|
jsonStr("_vmap_"+m.id),
|
||||||
|
jsonStr(m.id),
|
||||||
|
))
|
||||||
|
|
||||||
|
b.WriteString(`})()`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// markerBodyJS generates JS to add a marker, assuming `map` is in scope.
|
||||||
|
func markerBodyJS(mapID, markerID string, mk Marker) string {
|
||||||
|
var b strings.Builder
|
||||||
|
opts := "{"
|
||||||
|
if mk.Color != "" {
|
||||||
|
opts += fmt.Sprintf(`color:%s,`, jsonStr(mk.Color))
|
||||||
|
}
|
||||||
|
if mk.Draggable {
|
||||||
|
opts += `draggable:true,`
|
||||||
|
}
|
||||||
|
opts += "}"
|
||||||
|
|
||||||
|
// Determine initial position
|
||||||
|
if mk.LngSignal != nil && mk.LatSignal != nil {
|
||||||
|
b.WriteString(fmt.Sprintf(`var mk=new maplibregl.Marker(%s).setLngLat([%s,%s]);`,
|
||||||
|
opts, mk.LngSignal.String(), mk.LatSignal.String()))
|
||||||
|
} else {
|
||||||
|
b.WriteString(fmt.Sprintf(`var mk=new maplibregl.Marker(%s).setLngLat([%s,%s]);`,
|
||||||
|
opts, formatFloat(mk.LngLat.Lng), formatFloat(mk.LngLat.Lat)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if mk.Popup != nil {
|
||||||
|
b.WriteString(popupConstructorJS(*mk.Popup, "pk"))
|
||||||
|
b.WriteString(`mk.setPopup(pk);`)
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf(`mk.addTo(map);map._via_markers[%s]=mk;`, jsonStr(markerID)))
|
||||||
|
|
||||||
|
// Dragend → signal writeback
|
||||||
|
if mk.Draggable && mk.LngSignal != nil && mk.LatSignal != nil {
|
||||||
|
b.WriteString(dragendHandlerJS(mapID, markerID, mk))
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// dragendHandlerJS generates JS that writes marker position back to signal hidden inputs on dragend.
|
||||||
|
func dragendHandlerJS(mapID, markerID string, mk Marker) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`mk.on('dragend',function(){`+
|
||||||
|
`var pos=mk.getLngLat();`+
|
||||||
|
`var el=document.getElementById(%[1]s);if(!el)return;`+
|
||||||
|
`var inputs=el.querySelectorAll('input[data-bind]');`+
|
||||||
|
`inputs.forEach(function(inp){`+
|
||||||
|
`var sig=inp.getAttribute('data-bind');`+
|
||||||
|
`if(sig===%[2]s){inp.value=pos.lng;inp.dispatchEvent(new Event('input',{bubbles:true}))}`+
|
||||||
|
`if(sig===%[3]s){inp.value=pos.lat;inp.dispatchEvent(new Event('input',{bubbles:true}))}`+
|
||||||
|
`});`+
|
||||||
|
`});`,
|
||||||
|
jsonStr("_vwrap_"+mapID),
|
||||||
|
jsonStr(mk.LngSignal.ID()),
|
||||||
|
jsonStr(mk.LatSignal.ID()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// markerEffectExpr generates a data-effect expression that moves a signal-backed marker
|
||||||
|
// when its signals change.
|
||||||
|
func markerEffectExpr(mapID, markerID string, mk Marker) string {
|
||||||
|
// Read signals before the guard so Datastar tracks them as dependencies
|
||||||
|
// even when the map/marker hasn't loaded yet on first evaluation.
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`var lng=$%s,lat=$%s;`+
|
||||||
|
`var m=window.__via_maps&&window.__via_maps[%s];`+
|
||||||
|
`if(m&&m._via_markers[%s]){`+
|
||||||
|
`m._via_markers[%s].setLngLat([lng,lat])}`,
|
||||||
|
mk.LngSignal.ID(), mk.LatSignal.ID(),
|
||||||
|
jsonStr(mapID), jsonStr(markerID), jsonStr(markerID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addMarkerJS generates a self-contained IIFE to add a marker post-render.
|
||||||
|
func addMarkerJS(mapID, markerID string, mk Marker) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
`(function(){var map=window.__via_maps&&window.__via_maps[%s];if(!map)return;`,
|
||||||
|
jsonStr(mapID)))
|
||||||
|
// Remove existing marker with same ID
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
`if(map._via_markers[%[1]s]){map._via_markers[%[1]s].remove();delete map._via_markers[%[1]s];}`,
|
||||||
|
jsonStr(markerID)))
|
||||||
|
b.WriteString(markerBodyJS(mapID, markerID, mk))
|
||||||
|
b.WriteString(`})()`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeMarkerJS generates JS to remove a marker. Expects `m` in scope (used inside guard).
|
||||||
|
func removeMarkerJS(markerID string) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`if(m._via_markers[%[1]s]){m._via_markers[%[1]s].remove();delete m._via_markers[%[1]s];}`,
|
||||||
|
jsonStr(markerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// popupBodyJS generates JS to show a popup, assuming `map` is in scope.
|
||||||
|
func popupBodyJS(popupID string, p Popup) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(popupConstructorJS(p, "p"))
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
`p.setLngLat([%s,%s]).addTo(map);map._via_popups[%s]=p;`,
|
||||||
|
formatFloat(p.LngLat.Lng), formatFloat(p.LngLat.Lat), jsonStr(popupID)))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// showPopupJS generates a self-contained IIFE to show a popup post-render.
|
||||||
|
func showPopupJS(mapID, popupID string, p Popup) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
`(function(){var map=window.__via_maps&&window.__via_maps[%s];if(!map)return;`,
|
||||||
|
jsonStr(mapID)))
|
||||||
|
// Close existing popup with same ID
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
`if(map._via_popups[%[1]s]){map._via_popups[%[1]s].remove();delete map._via_popups[%[1]s];}`,
|
||||||
|
jsonStr(popupID)))
|
||||||
|
b.WriteString(popupBodyJS(popupID, p))
|
||||||
|
b.WriteString(`})()`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// closePopupJS generates JS to close a popup. Expects `m` in scope (used inside guard).
|
||||||
|
func closePopupJS(popupID string) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`if(m._via_popups[%[1]s]){m._via_popups[%[1]s].remove();delete m._via_popups[%[1]s];}`,
|
||||||
|
jsonStr(popupID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// popupConstructorJS generates JS to create a Popup object stored in varName.
|
||||||
|
func popupConstructorJS(p Popup, varName string) string {
|
||||||
|
opts := "{"
|
||||||
|
if p.HideCloseButton {
|
||||||
|
opts += `closeButton:false,`
|
||||||
|
}
|
||||||
|
if p.MaxWidth != "" {
|
||||||
|
opts += fmt.Sprintf(`maxWidth:%s,`, jsonStr(p.MaxWidth))
|
||||||
|
}
|
||||||
|
opts += "}"
|
||||||
|
return fmt.Sprintf(`var %s=new maplibregl.Popup(%s).setHTML(%s);`,
|
||||||
|
varName, opts, jsonStr(p.Content))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Control JS ---
|
||||||
|
|
||||||
|
// controlBodyJS generates JS to add a control, assuming `map` is in scope.
|
||||||
|
func controlBodyJS(controlID string, ctrl Control) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`var ctrl=%s;map.addControl(ctrl,%s);map._via_controls[%s]=ctrl;`,
|
||||||
|
ctrl.controlJS(), jsonStr(ctrl.controlPosition()), jsonStr(controlID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// addControlJS generates a self-contained IIFE to add a control post-render.
|
||||||
|
func addControlJS(mapID, controlID string, ctrl Control) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`(function(){var map=window.__via_maps&&window.__via_maps[%[1]s];if(!map)return;`+
|
||||||
|
`if(map._via_controls[%[2]s]){map.removeControl(map._via_controls[%[2]s]);delete map._via_controls[%[2]s];}`+
|
||||||
|
`var ctrl=%[3]s;map.addControl(ctrl,%[4]s);map._via_controls[%[2]s]=ctrl;`+
|
||||||
|
`})()`,
|
||||||
|
jsonStr(mapID), jsonStr(controlID), ctrl.controlJS(), jsonStr(ctrl.controlPosition()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeControlJS generates JS to remove a control. Expects `m` in scope.
|
||||||
|
func removeControlJS(controlID string) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`if(m._via_controls[%[1]s]){m.removeControl(m._via_controls[%[1]s]);delete m._via_controls[%[1]s];}`,
|
||||||
|
jsonStr(controlID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Event JS ---
|
||||||
|
|
||||||
|
// eventListenerJS generates JS to register a map event listener that writes
|
||||||
|
// event data to a hidden signal input.
|
||||||
|
func eventListenerJS(mapID string, ev eventEntry) string {
|
||||||
|
var handler string
|
||||||
|
if ev.layerID != "" {
|
||||||
|
handler = fmt.Sprintf(
|
||||||
|
`map.on(%[1]s,%[2]s,function(e){`+
|
||||||
|
`var d={lngLat:{Lng:e.lngLat.lng,Lat:e.lngLat.lat},point:[e.point.x,e.point.y],layerID:%[2]s};`+
|
||||||
|
`if(e.features)d.features=e.features.map(function(f){return JSON.parse(JSON.stringify(f))});`+
|
||||||
|
`var el=document.getElementById(%[3]s);if(!el)return;`+
|
||||||
|
`var inp=el.querySelector('input[data-bind=%[4]s]');`+
|
||||||
|
`if(inp){inp.value=JSON.stringify(d);inp.dispatchEvent(new Event('input',{bubbles:true}))}`+
|
||||||
|
`});`,
|
||||||
|
jsonStr(ev.event), jsonStr(ev.layerID),
|
||||||
|
jsonStr("_vwrap_"+mapID), jsonStr(ev.signal.ID()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
handler = fmt.Sprintf(
|
||||||
|
`map.on(%[1]s,function(e){`+
|
||||||
|
`var d={lngLat:{Lng:e.lngLat.lng,Lat:e.lngLat.lat},point:[e.point.x,e.point.y]};`+
|
||||||
|
`var el=document.getElementById(%[2]s);if(!el)return;`+
|
||||||
|
`var inp=el.querySelector('input[data-bind=%[3]s]');`+
|
||||||
|
`if(inp){inp.value=JSON.stringify(d);inp.dispatchEvent(new Event('input',{bubbles:true}))}`+
|
||||||
|
`});`,
|
||||||
|
jsonStr(ev.event),
|
||||||
|
jsonStr("_vwrap_"+mapID), jsonStr(ev.signal.ID()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Camera options JS ---
|
||||||
|
|
||||||
|
// cameraOptionsJS converts CameraOptions to a JS object literal string.
|
||||||
|
func cameraOptionsJS(opts CameraOptions) string {
|
||||||
|
obj := map[string]any{}
|
||||||
|
if opts.Center != nil {
|
||||||
|
obj["center"] = []float64{opts.Center.Lng, opts.Center.Lat}
|
||||||
|
}
|
||||||
|
if opts.Zoom != nil {
|
||||||
|
obj["zoom"] = *opts.Zoom
|
||||||
|
}
|
||||||
|
if opts.Bearing != nil {
|
||||||
|
obj["bearing"] = *opts.Bearing
|
||||||
|
}
|
||||||
|
if opts.Pitch != nil {
|
||||||
|
obj["pitch"] = *opts.Pitch
|
||||||
|
}
|
||||||
|
if opts.Duration != nil {
|
||||||
|
obj["duration"] = *opts.Duration
|
||||||
|
}
|
||||||
|
if opts.Speed != nil {
|
||||||
|
obj["speed"] = *opts.Speed
|
||||||
|
}
|
||||||
|
if opts.Curve != nil {
|
||||||
|
obj["curve"] = *opts.Curve
|
||||||
|
}
|
||||||
|
if opts.Padding != nil {
|
||||||
|
obj["padding"] = map[string]int{
|
||||||
|
"top": opts.Padding.Top,
|
||||||
|
"bottom": opts.Padding.Bottom,
|
||||||
|
"left": opts.Padding.Left,
|
||||||
|
"right": opts.Padding.Right,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opts.Animate != nil {
|
||||||
|
obj["animate"] = *opts.Animate
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(obj)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatFloat(f float64) string {
|
||||||
|
return fmt.Sprintf("%g", f)
|
||||||
|
}
|
||||||
1
maplibre/maplibre-gl.css
Normal file
1
maplibre/maplibre-gl.css
Normal file
File diff suppressed because one or more lines are too long
59
maplibre/maplibre-gl.js
Normal file
59
maplibre/maplibre-gl.js
Normal file
File diff suppressed because one or more lines are too long
440
maplibre/maplibre.go
Normal file
440
maplibre/maplibre.go
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
// Package maplibre provides a Go API for MapLibre GL JS maps within Via applications.
|
||||||
|
//
|
||||||
|
// It follows the same ExecScript + DataIgnoreMorph pattern used for other client-side
|
||||||
|
// JS library integrations (e.g. ECharts in the realtimechart example).
|
||||||
|
package maplibre
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/ryanhamamura/via"
|
||||||
|
"github.com/ryanhamamura/via/h"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed maplibre-gl.js
|
||||||
|
var maplibreJS []byte
|
||||||
|
|
||||||
|
//go:embed maplibre-gl.css
|
||||||
|
var maplibreCSS []byte
|
||||||
|
|
||||||
|
// Plugin serves the embedded MapLibre GL JS/CSS and injects them into the document head.
|
||||||
|
func Plugin(v *via.V) {
|
||||||
|
v.HTTPServeMux().HandleFunc("GET /_maplibre/maplibre-gl.js", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/javascript")
|
||||||
|
_, _ = w.Write(maplibreJS)
|
||||||
|
})
|
||||||
|
v.HTTPServeMux().HandleFunc("GET /_maplibre/maplibre-gl.css", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/css")
|
||||||
|
_, _ = w.Write(maplibreCSS)
|
||||||
|
})
|
||||||
|
v.AppendToHead(
|
||||||
|
h.Link(h.Rel("stylesheet"), h.Href("/_maplibre/maplibre-gl.css")),
|
||||||
|
h.Script(h.Src("/_maplibre/maplibre-gl.js")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map represents a MapLibre GL map instance bound to a Via context.
|
||||||
|
type Map struct {
|
||||||
|
// Viewport signals — readable with .Text(), .String(), etc.
|
||||||
|
CenterLng *via.Signal
|
||||||
|
CenterLat *via.Signal
|
||||||
|
Zoom *via.Signal
|
||||||
|
Bearing *via.Signal
|
||||||
|
Pitch *via.Signal
|
||||||
|
|
||||||
|
id string
|
||||||
|
ctx *via.Context
|
||||||
|
opts Options
|
||||||
|
|
||||||
|
sources []sourceEntry
|
||||||
|
layers []Layer
|
||||||
|
markers []markerEntry
|
||||||
|
popups []popupEntry
|
||||||
|
events []eventEntry
|
||||||
|
controls []controlEntry
|
||||||
|
|
||||||
|
rendered bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a Map bound to the given Via context with the provided options.
|
||||||
|
// It registers viewport signals on the context for browser → server sync.
|
||||||
|
func New(c *via.Context, opts Options) *Map {
|
||||||
|
if opts.Width == "" {
|
||||||
|
opts.Width = "100%"
|
||||||
|
}
|
||||||
|
if opts.Height == "" {
|
||||||
|
opts.Height = "400px"
|
||||||
|
}
|
||||||
|
|
||||||
|
m := &Map{
|
||||||
|
id: genID(),
|
||||||
|
ctx: c,
|
||||||
|
opts: opts,
|
||||||
|
}
|
||||||
|
|
||||||
|
m.CenterLng = c.Signal(opts.Center.Lng)
|
||||||
|
m.CenterLat = c.Signal(opts.Center.Lat)
|
||||||
|
m.Zoom = c.Signal(opts.Zoom)
|
||||||
|
m.Bearing = c.Signal(opts.Bearing)
|
||||||
|
m.Pitch = c.Signal(opts.Pitch)
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element returns the h.H DOM tree for the map. Call this once inside your View function.
|
||||||
|
// After Element() is called, subsequent source/layer/marker/popup operations
|
||||||
|
// use ExecScript instead of accumulating for the init script.
|
||||||
|
//
|
||||||
|
// Extra children are appended inside the wrapper div (useful for event inputs
|
||||||
|
// and data-effect binding elements).
|
||||||
|
func (m *Map) Element(extra ...h.H) h.H {
|
||||||
|
m.rendered = true
|
||||||
|
|
||||||
|
children := []h.H{
|
||||||
|
h.ID("_vwrap_" + m.id),
|
||||||
|
// Map container — morph-ignored so MapLibre's DOM isn't destroyed on Sync()
|
||||||
|
h.Div(
|
||||||
|
h.ID("_vmap_"+m.id),
|
||||||
|
h.DataIgnoreMorph(),
|
||||||
|
h.Attr("style", fmt.Sprintf("width:%s;height:%s", m.opts.Width, m.opts.Height)),
|
||||||
|
),
|
||||||
|
// Hidden inputs for viewport signal binding (outside morph-ignored zone)
|
||||||
|
h.Input(h.Type("hidden"), m.CenterLng.Bind()),
|
||||||
|
h.Input(h.Type("hidden"), m.CenterLat.Bind()),
|
||||||
|
h.Input(h.Type("hidden"), m.Zoom.Bind()),
|
||||||
|
h.Input(h.Type("hidden"), m.Bearing.Bind()),
|
||||||
|
h.Input(h.Type("hidden"), m.Pitch.Bind()),
|
||||||
|
}
|
||||||
|
|
||||||
|
// data-effect elements for signal-backed markers
|
||||||
|
for _, me := range m.markers {
|
||||||
|
if me.marker.LngSignal != nil && me.marker.LatSignal != nil {
|
||||||
|
children = append(children, h.Div(
|
||||||
|
h.Attr("style", "display:none"),
|
||||||
|
h.DataEffect(markerEffectExpr(m.id, me.id, me.marker)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hidden inputs for signal-backed marker position writeback (drag → signal)
|
||||||
|
for _, me := range m.markers {
|
||||||
|
if me.marker.LngSignal != nil && me.marker.LatSignal != nil {
|
||||||
|
children = append(children,
|
||||||
|
h.Input(h.Type("hidden"), me.marker.LngSignal.Bind()),
|
||||||
|
h.Input(h.Type("hidden"), me.marker.LatSignal.Bind()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
children = append(children, extra...)
|
||||||
|
|
||||||
|
// Init script last
|
||||||
|
children = append(children, h.Script(h.Raw(initScript(m))))
|
||||||
|
|
||||||
|
return h.Div(children...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Viewport readers (signal → Go) ---
|
||||||
|
|
||||||
|
// Center returns the current map center from synced signals.
|
||||||
|
func (m *Map) Center() LngLat {
|
||||||
|
return LngLat{
|
||||||
|
Lng: parseFloat(m.CenterLng.String()),
|
||||||
|
Lat: parseFloat(m.CenterLat.String()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Camera methods ---
|
||||||
|
|
||||||
|
// FlyTo animates the map to the target camera state.
|
||||||
|
func (m *Map) FlyTo(opts CameraOptions) {
|
||||||
|
m.exec(fmt.Sprintf(`m.flyTo(%s);`, cameraOptionsJS(opts)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// EaseTo eases the map to the target camera state.
|
||||||
|
func (m *Map) EaseTo(opts CameraOptions) {
|
||||||
|
m.exec(fmt.Sprintf(`m.easeTo(%s);`, cameraOptionsJS(opts)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// JumpTo jumps the map to the target camera state without animation.
|
||||||
|
func (m *Map) JumpTo(opts CameraOptions) {
|
||||||
|
m.exec(fmt.Sprintf(`m.jumpTo(%s);`, cameraOptionsJS(opts)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FitBounds fits the map to the given bounds with optional camera options.
|
||||||
|
func (m *Map) FitBounds(bounds LngLatBounds, opts ...CameraOptions) {
|
||||||
|
boundsJS := fmt.Sprintf("[[%s,%s],[%s,%s]]",
|
||||||
|
formatFloat(bounds.SW.Lng), formatFloat(bounds.SW.Lat),
|
||||||
|
formatFloat(bounds.NE.Lng), formatFloat(bounds.NE.Lat))
|
||||||
|
if len(opts) > 0 {
|
||||||
|
m.exec(fmt.Sprintf(`m.fitBounds(%s,%s);`, boundsJS, cameraOptionsJS(opts[0])))
|
||||||
|
} else {
|
||||||
|
m.exec(fmt.Sprintf(`m.fitBounds(%s);`, boundsJS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop aborts any in-progress camera animation.
|
||||||
|
func (m *Map) Stop() {
|
||||||
|
m.exec(`m.stop();`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCenter sets the map center without animation.
|
||||||
|
func (m *Map) SetCenter(ll LngLat) {
|
||||||
|
m.exec(fmt.Sprintf(`m.setCenter([%s,%s]);`,
|
||||||
|
formatFloat(ll.Lng), formatFloat(ll.Lat)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetZoom sets the map zoom level without animation.
|
||||||
|
func (m *Map) SetZoom(z float64) {
|
||||||
|
m.exec(fmt.Sprintf(`m.setZoom(%s);`, formatFloat(z)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBearing sets the map bearing without animation.
|
||||||
|
func (m *Map) SetBearing(b float64) {
|
||||||
|
m.exec(fmt.Sprintf(`m.setBearing(%s);`, formatFloat(b)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPitch sets the map pitch without animation.
|
||||||
|
func (m *Map) SetPitch(p float64) {
|
||||||
|
m.exec(fmt.Sprintf(`m.setPitch(%s);`, formatFloat(p)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStyle changes the map's style URL.
|
||||||
|
func (m *Map) SetStyle(url string) {
|
||||||
|
m.exec(fmt.Sprintf(`m.setStyle(%s);`, jsonStr(url)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Source methods ---
|
||||||
|
|
||||||
|
// AddSource adds a source to the map.
|
||||||
|
func (m *Map) AddSource(id string, src Source) {
|
||||||
|
js := src.sourceJS()
|
||||||
|
if !m.rendered {
|
||||||
|
m.sources = append(m.sources, sourceEntry{id: id, js: js})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.exec(fmt.Sprintf(`m.addSource(%s,%s);`, jsonStr(id), js))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveSource removes a source from the map.
|
||||||
|
func (m *Map) RemoveSource(id string) {
|
||||||
|
if !m.rendered {
|
||||||
|
for i, s := range m.sources {
|
||||||
|
if s.id == id {
|
||||||
|
m.sources = append(m.sources[:i], m.sources[i+1:]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.exec(fmt.Sprintf(`m.removeSource(%s);`, jsonStr(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateGeoJSONSource replaces the data of an existing GeoJSON source.
|
||||||
|
func (m *Map) UpdateGeoJSONSource(sourceID string, data any) {
|
||||||
|
m.exec(fmt.Sprintf(`m.getSource(%s).setData(%s);`, jsonStr(sourceID), jsonVal(data)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Layer methods ---
|
||||||
|
|
||||||
|
// AddLayer adds a layer to the map.
|
||||||
|
func (m *Map) AddLayer(layer Layer) {
|
||||||
|
if !m.rendered {
|
||||||
|
m.layers = append(m.layers, layer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
before := "undefined"
|
||||||
|
if layer.Before != "" {
|
||||||
|
before = jsonStr(layer.Before)
|
||||||
|
}
|
||||||
|
m.exec(fmt.Sprintf(`m.addLayer(%s,%s);`, layer.toJS(), before))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveLayer removes a layer from the map.
|
||||||
|
func (m *Map) RemoveLayer(id string) {
|
||||||
|
if !m.rendered {
|
||||||
|
for i, l := range m.layers {
|
||||||
|
if l.ID == id {
|
||||||
|
m.layers = append(m.layers[:i], m.layers[i+1:]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.exec(fmt.Sprintf(`m.removeLayer(%s);`, jsonStr(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPaintProperty sets a paint property on a layer.
|
||||||
|
func (m *Map) SetPaintProperty(layerID, name string, value any) {
|
||||||
|
m.exec(fmt.Sprintf(`m.setPaintProperty(%s,%s,%s);`,
|
||||||
|
jsonStr(layerID), jsonStr(name), jsonVal(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLayoutProperty sets a layout property on a layer.
|
||||||
|
func (m *Map) SetLayoutProperty(layerID, name string, value any) {
|
||||||
|
m.exec(fmt.Sprintf(`m.setLayoutProperty(%s,%s,%s);`,
|
||||||
|
jsonStr(layerID), jsonStr(name), jsonVal(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Marker methods ---
|
||||||
|
|
||||||
|
// AddMarker adds or replaces a marker on the map.
|
||||||
|
func (m *Map) AddMarker(id string, marker Marker) {
|
||||||
|
if !m.rendered {
|
||||||
|
m.markers = append(m.markers, markerEntry{id: id, marker: marker})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
js := addMarkerJS(m.id, id, marker)
|
||||||
|
m.ctx.ExecScript(js)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMarker removes a marker from the map.
|
||||||
|
func (m *Map) RemoveMarker(id string) {
|
||||||
|
if !m.rendered {
|
||||||
|
for i, me := range m.markers {
|
||||||
|
if me.id == id {
|
||||||
|
m.markers = append(m.markers[:i], m.markers[i+1:]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.exec(removeMarkerJS(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Popup methods ---
|
||||||
|
|
||||||
|
// ShowPopup shows a standalone popup on the map.
|
||||||
|
func (m *Map) ShowPopup(id string, popup Popup) {
|
||||||
|
if !m.rendered {
|
||||||
|
m.popups = append(m.popups, popupEntry{id: id, popup: popup})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
js := showPopupJS(m.id, id, popup)
|
||||||
|
m.ctx.ExecScript(js)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClosePopup closes a standalone popup on the map.
|
||||||
|
func (m *Map) ClosePopup(id string) {
|
||||||
|
if !m.rendered {
|
||||||
|
for i, pe := range m.popups {
|
||||||
|
if pe.id == id {
|
||||||
|
m.popups = append(m.popups[:i], m.popups[i+1:]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.exec(closePopupJS(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Control methods ---
|
||||||
|
|
||||||
|
// AddControl adds a control to the map.
|
||||||
|
func (m *Map) AddControl(id string, ctrl Control) {
|
||||||
|
if !m.rendered {
|
||||||
|
m.controls = append(m.controls, controlEntry{id: id, ctrl: ctrl})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.exec(addControlJS(m.id, id, ctrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveControl removes a control from the map.
|
||||||
|
func (m *Map) RemoveControl(id string) {
|
||||||
|
if !m.rendered {
|
||||||
|
for i, ce := range m.controls {
|
||||||
|
if ce.id == id {
|
||||||
|
m.controls = append(m.controls[:i], m.controls[i+1:]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.exec(removeControlJS(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Event methods ---
|
||||||
|
|
||||||
|
// OnClick returns a MapEvent that fires on map click.
|
||||||
|
func (m *Map) OnClick() *MapEvent {
|
||||||
|
return m.on("click", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnLayerClick returns a MapEvent that fires on click of a specific layer.
|
||||||
|
func (m *Map) OnLayerClick(layerID string) *MapEvent {
|
||||||
|
return m.on("click", layerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnMouseMove returns a MapEvent that fires on map mouse movement.
|
||||||
|
func (m *Map) OnMouseMove() *MapEvent {
|
||||||
|
return m.on("mousemove", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnContextMenu returns a MapEvent that fires on right-click.
|
||||||
|
func (m *Map) OnContextMenu() *MapEvent {
|
||||||
|
return m.on("contextmenu", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Map) on(event, layerID string) *MapEvent {
|
||||||
|
sig := m.ctx.Signal("")
|
||||||
|
ev := &MapEvent{signal: sig}
|
||||||
|
m.events = append(m.events, eventEntry{
|
||||||
|
event: event,
|
||||||
|
layerID: layerID,
|
||||||
|
signal: sig,
|
||||||
|
})
|
||||||
|
return ev
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Escape hatch ---
|
||||||
|
|
||||||
|
// Exec runs arbitrary JS with the map available as `m`.
|
||||||
|
func (m *Map) Exec(js string) {
|
||||||
|
m.exec(js)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exec sends guarded JS to the browser via ExecScript.
|
||||||
|
func (m *Map) exec(body string) {
|
||||||
|
m.ctx.ExecScript(guard(m.id, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MapEvent ---
|
||||||
|
|
||||||
|
// MapEvent wraps a signal that receives map event data as JSON.
|
||||||
|
type MapEvent struct {
|
||||||
|
signal *via.Signal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind returns the data-bind attribute for this event's signal.
|
||||||
|
func (e *MapEvent) Bind() h.H { return e.signal.Bind() }
|
||||||
|
|
||||||
|
// Data parses the event signal's JSON value into EventData.
|
||||||
|
func (e *MapEvent) Data() EventData {
|
||||||
|
var d EventData
|
||||||
|
json.Unmarshal([]byte(e.signal.String()), &d)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input creates a hidden input wired to this event's signal.
|
||||||
|
// Pass action triggers (e.g. handleClick.OnInput()) as attrs.
|
||||||
|
func (e *MapEvent) Input(attrs ...h.H) h.H {
|
||||||
|
all := append([]h.H{h.Type("hidden"), e.Bind()}, attrs...)
|
||||||
|
return h.Input(all...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFloat(s string) float64 {
|
||||||
|
f, _ := strconv.ParseFloat(s, 64)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func genID() string {
|
||||||
|
b := make([]byte, 4)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
359
maplibre/types.go
Normal file
359
maplibre/types.go
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
package maplibre
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/ryanhamamura/via"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LngLat represents a geographic coordinate.
|
||||||
|
type LngLat struct {
|
||||||
|
Lng float64
|
||||||
|
Lat float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// LngLatBounds represents a rectangular geographic area.
|
||||||
|
type LngLatBounds struct {
|
||||||
|
SW LngLat
|
||||||
|
NE LngLat
|
||||||
|
}
|
||||||
|
|
||||||
|
// Padding represents padding in pixels on each side of the map viewport.
|
||||||
|
type Padding struct {
|
||||||
|
Top int
|
||||||
|
Bottom int
|
||||||
|
Left int
|
||||||
|
Right int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options configures the initial map state.
|
||||||
|
type Options struct {
|
||||||
|
// Style is the map style URL (required).
|
||||||
|
Style string
|
||||||
|
|
||||||
|
Center LngLat
|
||||||
|
Zoom float64
|
||||||
|
Bearing float64
|
||||||
|
Pitch float64
|
||||||
|
MinZoom float64
|
||||||
|
MaxZoom float64
|
||||||
|
|
||||||
|
// CSS dimensions for the map container. Defaults: "100%", "400px".
|
||||||
|
Width string
|
||||||
|
Height string
|
||||||
|
|
||||||
|
// Interaction toggles (nil = MapLibre default)
|
||||||
|
ScrollZoom *bool
|
||||||
|
BoxZoom *bool
|
||||||
|
DragRotate *bool
|
||||||
|
DragPan *bool
|
||||||
|
Keyboard *bool
|
||||||
|
DoubleClickZoom *bool
|
||||||
|
TouchZoomRotate *bool
|
||||||
|
TouchPitch *bool
|
||||||
|
RenderWorldCopies *bool
|
||||||
|
|
||||||
|
MaxBounds *LngLatBounds
|
||||||
|
|
||||||
|
// Extra is merged last into the MapLibre constructor options object,
|
||||||
|
// allowing pass-through of any option not covered above.
|
||||||
|
Extra map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Source interface ---
|
||||||
|
|
||||||
|
// Source is implemented by map data sources (GeoJSON, vector, raster, etc.).
|
||||||
|
type Source interface {
|
||||||
|
sourceJS() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeoJSONSource provides inline GeoJSON data to MapLibre.
|
||||||
|
// Data should be a GeoJSON-marshalable value (struct, map, or json.RawMessage).
|
||||||
|
type GeoJSONSource struct {
|
||||||
|
Data any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s GeoJSONSource) sourceJS() string {
|
||||||
|
data, _ := json.Marshal(s.Data)
|
||||||
|
return `{"type":"geojson","data":` + string(data) + `}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VectorSource references a vector tile source.
|
||||||
|
type VectorSource struct {
|
||||||
|
URL string
|
||||||
|
Tiles []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s VectorSource) sourceJS() string {
|
||||||
|
obj := map[string]any{"type": "vector"}
|
||||||
|
if s.URL != "" {
|
||||||
|
obj["url"] = s.URL
|
||||||
|
}
|
||||||
|
if len(s.Tiles) > 0 {
|
||||||
|
obj["tiles"] = s.Tiles
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(obj)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RasterSource references a raster tile source.
|
||||||
|
type RasterSource struct {
|
||||||
|
URL string
|
||||||
|
Tiles []string
|
||||||
|
TileSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s RasterSource) sourceJS() string {
|
||||||
|
obj := map[string]any{"type": "raster"}
|
||||||
|
if s.URL != "" {
|
||||||
|
obj["url"] = s.URL
|
||||||
|
}
|
||||||
|
if len(s.Tiles) > 0 {
|
||||||
|
obj["tiles"] = s.Tiles
|
||||||
|
}
|
||||||
|
if s.TileSize > 0 {
|
||||||
|
obj["tileSize"] = s.TileSize
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(obj)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawSource is an escape hatch that passes an arbitrary JSON-marshalable
|
||||||
|
// value directly as a MapLibre source definition.
|
||||||
|
type RawSource struct {
|
||||||
|
Value any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s RawSource) sourceJS() string {
|
||||||
|
b, _ := json.Marshal(s.Value)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Control interface ---
|
||||||
|
|
||||||
|
// Control is implemented by map controls (navigation, scale, etc.).
|
||||||
|
type Control interface {
|
||||||
|
controlJS() string
|
||||||
|
controlPosition() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NavigationControl adds zoom and rotation buttons.
|
||||||
|
type NavigationControl struct {
|
||||||
|
Position string // "top-right" (default), "top-left", "bottom-right", "bottom-left"
|
||||||
|
ShowCompass *bool
|
||||||
|
ShowZoom *bool
|
||||||
|
VisualizeRoll *bool
|
||||||
|
VisualizePitch *bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c NavigationControl) controlJS() string {
|
||||||
|
opts := map[string]any{}
|
||||||
|
if c.ShowCompass != nil {
|
||||||
|
opts["showCompass"] = *c.ShowCompass
|
||||||
|
}
|
||||||
|
if c.ShowZoom != nil {
|
||||||
|
opts["showZoom"] = *c.ShowZoom
|
||||||
|
}
|
||||||
|
if c.VisualizeRoll != nil {
|
||||||
|
opts["visualizeRoll"] = *c.VisualizeRoll
|
||||||
|
}
|
||||||
|
if c.VisualizePitch != nil {
|
||||||
|
opts["visualizePitch"] = *c.VisualizePitch
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(opts)
|
||||||
|
return "new maplibregl.NavigationControl(" + string(b) + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c NavigationControl) controlPosition() string {
|
||||||
|
if c.Position == "" {
|
||||||
|
return "top-right"
|
||||||
|
}
|
||||||
|
return c.Position
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScaleControl displays a scale bar.
|
||||||
|
type ScaleControl struct {
|
||||||
|
Position string // default "bottom-left"
|
||||||
|
MaxWidth int
|
||||||
|
Unit string // "metric", "imperial", "nautical"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c ScaleControl) controlJS() string {
|
||||||
|
opts := map[string]any{}
|
||||||
|
if c.MaxWidth > 0 {
|
||||||
|
opts["maxWidth"] = c.MaxWidth
|
||||||
|
}
|
||||||
|
if c.Unit != "" {
|
||||||
|
opts["unit"] = c.Unit
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(opts)
|
||||||
|
return "new maplibregl.ScaleControl(" + string(b) + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c ScaleControl) controlPosition() string {
|
||||||
|
if c.Position == "" {
|
||||||
|
return "bottom-left"
|
||||||
|
}
|
||||||
|
return c.Position
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeolocateControl adds a button to track the user's location.
|
||||||
|
type GeolocateControl struct {
|
||||||
|
Position string // default "top-right"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c GeolocateControl) controlJS() string {
|
||||||
|
return "new maplibregl.GeolocateControl()"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c GeolocateControl) controlPosition() string {
|
||||||
|
if c.Position == "" {
|
||||||
|
return "top-right"
|
||||||
|
}
|
||||||
|
return c.Position
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullscreenControl adds a fullscreen toggle button.
|
||||||
|
type FullscreenControl struct {
|
||||||
|
Position string // default "top-right"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c FullscreenControl) controlJS() string {
|
||||||
|
return "new maplibregl.FullscreenControl()"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c FullscreenControl) controlPosition() string {
|
||||||
|
if c.Position == "" {
|
||||||
|
return "top-right"
|
||||||
|
}
|
||||||
|
return c.Position
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Camera options ---
|
||||||
|
|
||||||
|
// CameraOptions configures animated camera movements (FlyTo, EaseTo, JumpTo).
|
||||||
|
// Nil pointer fields are omitted from the JS call.
|
||||||
|
type CameraOptions struct {
|
||||||
|
Center *LngLat
|
||||||
|
Zoom *float64
|
||||||
|
Bearing *float64
|
||||||
|
Pitch *float64
|
||||||
|
Duration *int // milliseconds
|
||||||
|
Speed *float64 // FlyTo only
|
||||||
|
Curve *float64 // FlyTo only
|
||||||
|
Padding *Padding
|
||||||
|
Animate *bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Layer ---
|
||||||
|
|
||||||
|
// Layer describes a MapLibre style layer.
|
||||||
|
type Layer struct {
|
||||||
|
ID string
|
||||||
|
Type string
|
||||||
|
Source string
|
||||||
|
SourceLayer string
|
||||||
|
Paint map[string]any
|
||||||
|
Layout map[string]any
|
||||||
|
Filter any
|
||||||
|
MinZoom float64
|
||||||
|
MaxZoom float64
|
||||||
|
|
||||||
|
// Before inserts this layer before the given layer ID in the stack.
|
||||||
|
Before string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Layer) toJS() string {
|
||||||
|
obj := map[string]any{
|
||||||
|
"id": l.ID,
|
||||||
|
"type": l.Type,
|
||||||
|
}
|
||||||
|
if l.Source != "" {
|
||||||
|
obj["source"] = l.Source
|
||||||
|
}
|
||||||
|
if l.SourceLayer != "" {
|
||||||
|
obj["source-layer"] = l.SourceLayer
|
||||||
|
}
|
||||||
|
if l.Paint != nil {
|
||||||
|
obj["paint"] = l.Paint
|
||||||
|
}
|
||||||
|
if l.Layout != nil {
|
||||||
|
obj["layout"] = l.Layout
|
||||||
|
}
|
||||||
|
if l.Filter != nil {
|
||||||
|
obj["filter"] = l.Filter
|
||||||
|
}
|
||||||
|
if l.MinZoom > 0 {
|
||||||
|
obj["minzoom"] = l.MinZoom
|
||||||
|
}
|
||||||
|
if l.MaxZoom > 0 {
|
||||||
|
obj["maxzoom"] = l.MaxZoom
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(obj)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Marker ---
|
||||||
|
|
||||||
|
// Marker describes a map marker.
|
||||||
|
type Marker struct {
|
||||||
|
LngLat LngLat // static position (used when signals are nil)
|
||||||
|
Color string
|
||||||
|
Draggable bool
|
||||||
|
Popup *Popup
|
||||||
|
|
||||||
|
// Signal-backed position. When set, signals drive marker position reactively.
|
||||||
|
// Initial position is read from the signal values. LngLat is ignored when signals are set.
|
||||||
|
// If Draggable is true, drag updates write back to these signals.
|
||||||
|
LngSignal *via.Signal
|
||||||
|
LatSignal *via.Signal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Popup describes a map popup.
|
||||||
|
//
|
||||||
|
// Content is rendered as HTML via MapLibre's setHTML. Do not pass untrusted
|
||||||
|
// user input without sanitizing it first.
|
||||||
|
type Popup struct {
|
||||||
|
Content string // HTML content
|
||||||
|
LngLat LngLat
|
||||||
|
HideCloseButton bool // true removes the close button (MapLibre shows it by default)
|
||||||
|
MaxWidth string
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Event data ---
|
||||||
|
|
||||||
|
// EventData contains data from a map event (click, mousemove, etc.).
|
||||||
|
type EventData struct {
|
||||||
|
LngLat LngLat `json:"lngLat"`
|
||||||
|
Point [2]float64 `json:"point"`
|
||||||
|
Features []json.RawMessage `json:"features,omitempty"`
|
||||||
|
LayerID string `json:"layerID,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internal accumulation entries ---
|
||||||
|
|
||||||
|
type sourceEntry struct {
|
||||||
|
id string
|
||||||
|
js string
|
||||||
|
}
|
||||||
|
|
||||||
|
type markerEntry struct {
|
||||||
|
id string
|
||||||
|
marker Marker
|
||||||
|
}
|
||||||
|
|
||||||
|
type popupEntry struct {
|
||||||
|
id string
|
||||||
|
popup Popup
|
||||||
|
}
|
||||||
|
|
||||||
|
type eventEntry struct {
|
||||||
|
event string
|
||||||
|
layerID string
|
||||||
|
signal *via.Signal
|
||||||
|
}
|
||||||
|
|
||||||
|
type controlEntry struct {
|
||||||
|
id string
|
||||||
|
ctrl Control
|
||||||
|
}
|
||||||
10
nats.go
10
nats.go
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/delaneyj/toolbelt/embeddednats"
|
"github.com/delaneyj/toolbelt/embeddednats"
|
||||||
|
natsserver "github.com/nats-io/nats-server/v2/server"
|
||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,7 +51,14 @@ func startDefaultNATS() (dn *defaultNATS, err error) {
|
|||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
ns, err := embeddednats.New(ctx, embeddednats.WithDirectory(dataDir))
|
ns, err := embeddednats.New(ctx,
|
||||||
|
embeddednats.WithDirectory(dataDir),
|
||||||
|
embeddednats.WithNATSServerOptions(&natsserver.Options{
|
||||||
|
JetStream: true,
|
||||||
|
StoreDir: dataDir,
|
||||||
|
Port: -1,
|
||||||
|
}),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cancel()
|
cancel()
|
||||||
os.RemoveAll(dataDir)
|
os.RemoveAll(dataDir)
|
||||||
|
|||||||
27
signal.go
27
signal.go
@@ -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 signal’s value and marks it for synchronization with the browser.
|
// SetValue updates the signal’s 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
169
via.go
169
via.go
@@ -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,11 +138,8 @@ func (v *V) Config(cfg Options) {
|
|||||||
v.defaultNATS = nil
|
v.defaultNATS = nil
|
||||||
v.pubsub = cfg.PubSub
|
v.pubsub = cfg.PubSub
|
||||||
}
|
}
|
||||||
if cfg.ContextSuspendAfter != 0 {
|
if cfg.Streams != nil {
|
||||||
v.cfg.ContextSuspendAfter = cfg.ContextSuspendAfter
|
v.cfg.Streams = cfg.Streams
|
||||||
}
|
|
||||||
if cfg.ContextTTL != 0 {
|
|
||||||
v.cfg.ContextTTL = cfg.ContextTTL
|
|
||||||
}
|
}
|
||||||
if cfg.ActionRateLimit.Rate != 0 || cfg.ActionRateLimit.Burst != 0 {
|
if cfg.ActionRateLimit.Rate != 0 || cfg.ActionRateLimit.Burst != 0 {
|
||||||
v.actionRateLimit = cfg.ActionRateLimit
|
v.actionRateLimit = cfg.ActionRateLimit
|
||||||
@@ -289,72 +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
|
|
||||||
}
|
|
||||||
var disconnectedFor time.Duration
|
|
||||||
if dc := c.sseDisconnectedAt.Load(); dc != nil {
|
|
||||||
disconnectedFor = now.Sub(*dc)
|
|
||||||
} else {
|
|
||||||
disconnectedFor = now.Sub(c.createdAt)
|
|
||||||
}
|
|
||||||
if disconnectedFor > ttl {
|
|
||||||
toReap = append(toReap, c)
|
|
||||||
} else if disconnectedFor > 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() {
|
||||||
@@ -368,6 +298,12 @@ func (v *V) Start() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, sc := range v.cfg.Streams {
|
||||||
|
if err := EnsureStream(v, sc); err != nil {
|
||||||
|
v.logger.Fatal().Err(err).Msgf("failed to create stream %q", sc.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handler := http.Handler(v.mux)
|
handler := http.Handler(v.mux)
|
||||||
if v.sessionManager != nil {
|
if v.sessionManager != nil {
|
||||||
handler = v.sessionManager.LoadAndSave(v.mux)
|
handler = v.sessionManager.LoadAndSave(v.mux)
|
||||||
@@ -377,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()
|
||||||
@@ -405,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()
|
||||||
|
|
||||||
@@ -508,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")
|
||||||
}
|
}
|
||||||
@@ -545,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")
|
||||||
}
|
}
|
||||||
@@ -676,29 +594,24 @@ 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)
|
||||||
|
defer keepalive.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-sse.Context().Done():
|
case <-sse.Context().Done():
|
||||||
v.logDebug(c, "SSE connection ended")
|
v.logDebug(c, "SSE connection ended")
|
||||||
c.sseConnected.Store(false)
|
c.sseConnected.Store(false)
|
||||||
now := time.Now()
|
dcNow := time.Now()
|
||||||
c.sseDisconnectedAt.Store(&now)
|
c.sseDisconnectedAt.Store(&dcNow)
|
||||||
return
|
return
|
||||||
case <-c.ctxDisposedChan:
|
case <-c.ctxDisposedChan:
|
||||||
v.logDebug(c, "context disposed, closing SSE")
|
v.logDebug(c, "context disposed, closing SSE")
|
||||||
return
|
return
|
||||||
|
case <-keepalive.C:
|
||||||
|
sse.PatchSignals([]byte("{}"))
|
||||||
case patch := <-c.patchChan:
|
case patch := <-c.patchChan:
|
||||||
switch patch.typ {
|
switch patch.typ {
|
||||||
case patchTypeElements:
|
case patchTypeElements:
|
||||||
|
|||||||
96
via_test.go
96
via_test.go
@@ -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,92 +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)
|
|
||||||
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)
|
|
||||||
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)
|
|
||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user