5 Commits

Author SHA1 Message Date
Ryan Hamamura
e63ebd1401 ci: re-trigger workflow
Some checks failed
CI / Build and Test (push) Has been cancelled
2026-02-19 14:49:48 -10:00
Ryan Hamamura
b26ded951f ci: trigger initial workflow run
Some checks failed
CI / Build and Test (push) Has been cancelled
2026-02-19 14:45:19 -10:00
Ryan Hamamura
8bb1b99ae9 chore: add PR workflow and worktree-aware release process
Some checks failed
CI / Build and Test (push) Has been cancelled
2026-02-19 14:43:46 -10:00
Ryan Hamamura
0d8bf04446 chore: add worktree support for parallel Claude Code sessions
Some checks failed
CI / Build and Test (push) Has been cancelled
2026-02-19 13:57:06 -10:00
Ryan Hamamura
742212fd20 feat: add maplibre subpackage for type-safe MapLibre GL JS maps
Some checks failed
CI / Build and Test (push) Has been cancelled
Provides a Go API for interactive maps within Via applications:
- Plugin serves vendored MapLibre GL JS v4.7.1 assets
- Map struct with pre/post-render source, layer, marker, popup management
- Viewport signal sync (center, zoom, bearing, pitch) via hidden inputs
- FlyTo, SetCenter, SetZoom and other viewport setters via ExecScript
- Idempotent init script with SPA cleanup via MutationObserver
- Example app demonstrating markers, GeoJSON layers, and FlyTo actions
2026-02-19 13:37:16 -10:00
12 changed files with 976 additions and 10 deletions

View File

@@ -0,0 +1,12 @@
Create a PR from the current branch on both GitHub and Gitea.
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: `git fetch origin main`.
4. Rebase onto main: `git rebase origin/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.
5. Push the branch to origin with `-u` (use `--force-with-lease` if the branch was already pushed).
6. Push the branch to gitea: `git push gitea <branch>`.
7. Create a GitHub PR: `gh pr create`. Reference related issues with `#X`. Only use `Closes #X` if the PR fully resolves the issue.
8. Create a Gitea PR: `tea pr create --head <branch> --base main` with the same title and description.
9. Report both PR URLs.

View File

@@ -0,0 +1,9 @@
PR checks pass. Squash and merge the PR on both GitHub and Gitea.
1. Squash-merge on GitHub: `gh pr merge --squash` with a clean, semantic commit message including the PR number. No Claude attribution lines.
2. Squash-merge on Gitea: `tea pr merge <index> --style squash` with the same message.
3. Push main to gitea to keep commits in sync: `git push gitea main`.
4. Delete the remote feature branch on origin: `git push origin --delete <branch>`.
5. Delete the remote feature branch on gitea: `git push gitea --delete <branch>`.
6. Prune remote tracking refs: `git remote prune origin && git remote prune gitea`.
7. If in a worktree, leave the local branch alone — Claude Code handles worktree cleanup on session exit. If NOT in a worktree, delete the local feature branch and switch to main.

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

@@ -0,0 +1,16 @@
Create a PR, wait for CI, and squash-merge it on both GitHub and Gitea. This is the standard single-command workflow.
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 origin main && git rebase origin/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 origin with `-u` (use `--force-with-lease` if already pushed). Also push to gitea.
5. Create a GitHub PR: `gh pr create`. Reference related issues with `#X`. Only use `Closes #X` if the PR fully resolves the issue.
6. Create a Gitea PR: `tea pr create --head <branch> --base main` with the same title and description.
7. Wait for CI to pass: poll with `gh pr checks` or `gh run watch`. If CI fails, report the failure and stop — do not merge.
8. Once CI passes, squash-merge on GitHub: `gh pr merge --squash` with a clean, semantic commit message including the PR number. No Claude attribution lines.
9. Squash-merge on Gitea: `tea pr merge <index> --style squash` with the same message.
10. Push main to gitea: `git push gitea main`.
11. Clean up remote branches: `git push origin --delete <branch> && git push gitea --delete <branch>`.
12. Prune refs: `git remote prune origin && git remote prune gitea`.
13. Report both merged PR URLs.

View File

@@ -1,14 +1,21 @@
Create a new release for this project. Steps:
Create a new release for this project.
1. Fetch tags from all remotes so the version list is current.
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:
## Pre-flight
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
- **minor**: new features, meaningful new behavior
- **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.
4. Tag the new version and push the tag + commits to all remotes (origin, gitea, etc.).
5. Generate release notes from the commits since the last tag, grouped by type (features, fixes, docs/refactoring).
6. Create a GitHub release using `gh release create`.
7. Create a Gitea release using `tea releases create` with the same notes.
8. Report both release URLs and confirm all remotes are up to date.
Present the proposed version, bump rationale, and commit list. Wait for user approval.
6. Tag the new version. Push the tag to all remotes (origin, gitea).
7. Generate release notes grouped by type (features, fixes, chores).
8. Create a GitHub release with `gh release create`.
9. Create a Gitea release with `tea releases create` using the same notes.
10. Report both release URLs and confirm all remotes are up to date.

3
.gitignore vendored
View File

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

21
CLAUDE.md Normal file
View File

@@ -0,0 +1,21 @@
# 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.
(Or use `/pr-create` and `/pr-merge` separately for more control.)
## Releasing
Run `/release` from a **non-worktree session on main**. It tags and publishes
what is already on main — it does not commit new changes.
## Worktree Usage
Always enter a worktree at the start of a session using the `EnterWorktree`
tool. This prevents parallel Claude Code sessions from interfering with each
other.

View File

@@ -0,0 +1,111 @@
package main
import (
"fmt"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
"github.com/ryanhamamura/via/maplibre"
)
func main() {
v := via.New()
v.Config(via.Options{
DocumentTitle: "MapLibre GL Example",
ServerAddress: ":7331",
DevMode: true,
Plugins: []via.Plugin{maplibre.Plugin},
})
v.Page("/", func(c *via.Context) {
m := maplibre.New(c, maplibre.Options{
Style: "https://demotiles.maplibre.org/style.json",
Center: maplibre.LngLat{Lng: -122.4194, Lat: 37.7749},
Zoom: 10,
Height: "500px",
})
// 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>",
},
})
// 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,
},
})
// Viewport info signal (updated on action)
viewportInfo := c.Signal("")
// FlyTo action
flyToSF := c.Action(func() {
m.FlyTo(maplibre.LngLat{Lng: -122.4194, Lat: 37.7749}, 14)
})
flyToOak := c.Action(func() {
m.FlyTo(maplibre.LngLat{Lng: -122.2711, Lat: 37.8044}, 14)
})
// Read viewport action
readViewport := c.Action(func() {
center := m.Center()
zoom := m.Zoom()
viewportInfo.SetValue(fmt.Sprintf("Center: %.4f, %.4f | Zoom: %.1f", center.Lng, center.Lat, zoom))
c.Sync()
})
c.View(func() h.H {
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(),
h.Div(h.Attr("style", "margin-top:1rem;display:flex;gap:0.5rem"),
h.Button(h.Text("Fly to San Francisco"), flyToSF.OnClick()),
h.Button(h.Text("Fly to Oakland"), flyToOak.OnClick()),
h.Button(h.Text("Read Viewport"), readViewport.OnClick()),
),
h.P(viewportInfo.Text()),
),
)
})
})
v.Start()
}

226
maplibre/js.go Normal file
View File

@@ -0,0 +1,226 @@
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),
))
b.WriteString(fmt.Sprintf(
`var map=new maplibregl.Map({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)))
}
b.WriteString(`});`)
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={};`)
// Pre-render sources, layers, markers, popups run on 'load'
if len(m.sources) > 0 || len(m.layers) > 0 || len(m.markers) > 0 || len(m.popups) > 0 {
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(me.id, me.marker))
}
for _, pe := range m.popups {
b.WriteString(popupBodyJS(pe.id, pe.popup))
}
b.WriteString(`});`)
}
// 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();`+
`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.
// Used inside the init script's load callback.
func markerBodyJS(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 += "}"
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)))
return b.String()
}
// 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(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))
}
func formatFloat(f float64) string {
return fmt.Sprintf("%g", f)
}

1
maplibre/maplibre-gl.css Normal file

File diff suppressed because one or more lines are too long

59
maplibre/maplibre-gl.js Normal file

File diff suppressed because one or more lines are too long

326
maplibre/maplibre.go Normal file
View File

@@ -0,0 +1,326 @@
// 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"
"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")),
)
}
// viaSignal is the interface satisfied by via's *signal type.
type viaSignal interface {
ID() string
String() string
SetValue(any)
Bind() h.H
}
// Map represents a MapLibre GL map instance bound to a Via context.
type Map struct {
id string
ctx *via.Context
opts Options
// Viewport signals for browser → server sync
centerLng, centerLat viaSignal
zoom, bearing, pitch viaSignal
// Pre-render accumulation
sources []sourceEntry
layers []Layer
markers []markerEntry
popups []popupEntry
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.
func (m *Map) Element() h.H {
m.rendered = true
return h.Div(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()),
// Init script
h.Script(h.Raw(initScript(m))),
)
}
// --- 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()),
}
}
// Zoom returns the current map zoom level from the synced signal.
func (m *Map) Zoom() float64 {
return parseFloat(m.zoom.String())
}
// Bearing returns the current map bearing from the synced signal.
func (m *Map) Bearing() float64 {
return parseFloat(m.bearing.String())
}
// Pitch returns the current map pitch from the synced signal.
func (m *Map) Pitch() float64 {
return parseFloat(m.pitch.String())
}
// --- Viewport setters (Go → browser) ---
// FlyTo animates the map to the given center and zoom.
func (m *Map) FlyTo(center LngLat, zoom float64) {
m.exec(fmt.Sprintf(`m.flyTo({center:[%s,%s],zoom:%s});`,
formatFloat(center.Lng), formatFloat(center.Lat), formatFloat(zoom)))
}
// 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. src should be a GeoJSONSource,
// VectorSource, RasterSource, or any JSON-marshalable value.
func (m *Map) AddSource(id string, src any) {
js := sourceJSON(src)
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.
// Before render, it removes a previously accumulated source. After render, it issues an ExecScript.
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.
// Before render, it removes a previously accumulated layer. After render, it issues an ExecScript.
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.
// Before render, it removes a previously accumulated marker. After render, it issues an ExecScript.
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.
// Before render, it removes a previously accumulated popup. After render, it issues an ExecScript.
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))
}
// --- 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))
}
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)
}

175
maplibre/types.go Normal file
View File

@@ -0,0 +1,175 @@
package maplibre
import "encoding/json"
// LngLat represents a geographic coordinate.
type LngLat struct {
Lng float64
Lat float64
}
// 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
}
// 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) toJS() 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) toJS() 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) toJS() 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)
}
// sourceJSON converts a source value to its JS object literal string.
func sourceJSON(src any) string {
switch s := src.(type) {
case GeoJSONSource:
return s.toJS()
case VectorSource:
return s.toJS()
case RasterSource:
return s.toJS()
default:
b, _ := json.Marshal(src)
return string(b)
}
}
// 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 describes a map marker.
type Marker struct {
LngLat LngLat
Color string
Draggable bool
Popup *Popup
}
// 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
}
// sourceEntry pairs a source ID with its JS representation for pre-render accumulation.
type sourceEntry struct {
id string
js string
}
// markerEntry pairs a marker ID with its definition for pre-render accumulation.
type markerEntry struct {
id string
marker Marker
}
// popupEntry pairs a popup ID with its definition for pre-render accumulation.
type popupEntry struct {
id string
popup Popup
}