Compare commits
5 Commits
v0.20.0
...
e63ebd1401
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e63ebd1401 | ||
|
|
b26ded951f | ||
|
|
8bb1b99ae9 | ||
|
|
0d8bf04446 | ||
|
|
742212fd20 |
12
.claude/commands/pr-create.md
Normal file
12
.claude/commands/pr-create.md
Normal 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.
|
||||||
9
.claude/commands/pr-merge.md
Normal file
9
.claude/commands/pr-merge.md
Normal 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
16
.claude/commands/pr.md
Normal 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.
|
||||||
@@ -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.
|
## 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 all remotes (origin, gitea).
|
||||||
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 GitHub release with `gh release create`.
|
||||||
7. Create a Gitea release using `tea releases create` with the same notes.
|
9. Create a Gitea release with `tea releases create` using the same notes.
|
||||||
8. Report both release URLs and confirm all remotes are up to date.
|
10. Report both release URLs and confirm all remotes are up to date.
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -52,3 +52,6 @@ internal/examples/nats-chatroom/nats-chatroom
|
|||||||
|
|
||||||
# NATS data directory
|
# NATS data directory
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# Claude Code worktrees
|
||||||
|
.claude/worktrees/
|
||||||
|
|||||||
21
CLAUDE.md
Normal file
21
CLAUDE.md
Normal 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.
|
||||||
111
internal/examples/maplibre/main.go
Normal file
111
internal/examples/maplibre/main.go
Normal 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
226
maplibre/js.go
Normal 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
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
326
maplibre/maplibre.go
Normal file
326
maplibre/maplibre.go
Normal 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
175
maplibre/types.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user