Skip to content

Instantly share code, notes, and snippets.

@sug0
Last active November 4, 2021 13:16
Show Gist options
  • Select an option

  • Save sug0/1c114b77f69e4ca335a870ceb5478d56 to your computer and use it in GitHub Desktop.

Select an option

Save sug0/1c114b77f69e4ca335a870ceb5478d56 to your computer and use it in GitHub Desktop.
Collaborative OSM based road trip planner in Go
package main
import (
"os"
"os/signal"
"syscall"
"net/http"
"context"
"sync"
"time"
"encoding/gob"
"bytes"
"nhooyr.io/websocket"
"nhooyr.io/websocket/wsjson"
bolt "github.com/coreos/bbolt"
)
type Marker struct {
Lat float64
Lng float64
}
type MarkerAction struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Del bool `json:"del,omitempty"`
}
type Clients struct {
mux sync.RWMutex
cli map[*websocket.Conn]struct{}
}
type Markers struct {
dirty bool
mux sync.RWMutex
sto map[Marker]uint8
}
const (
markerNonexistant = iota
markerToBeSavedDB
markerSavedDB
markerToBeDeletedDB
)
var (
// global vars
gClients *Clients
gMarkers *Markers
)
var indexHtml = []byte(`<html>
<head>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.5.1/dist/leaflet.css" integrity="sha512-xwE/Az9zrjBIphAcBb3F6JVqxf46+CDLwfLMHloNu6KEQCAWi6HcDUbeOfBIptF7tcCzusKFjFw2yuvEpDL9wQ==" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.5.1/dist/leaflet.js" integrity="sha512-GffPMF3RvMeYyc1LWMHtK8EbPv0iNZ8/oTtHPx9/cc2ILxQ+u905qIwdpULaqDkyBKgOaB57QTMg7ztg8Jm2Og==" crossorigin=""></script>
</head>
<body>
<script type="application/javascript">
function resizeMap() {
mapDiv.style.height = window.innerHeight - 20;
map.invalidateSize();
}
function newMarker(p, broadcast) {
let marker = L.marker(p);
marker.on('contextmenu', e => {
if (confirm('Delete marker?')) {
ws.send(JSON.stringify({lat: p.lat, lng: p.lng, del: true}));
marker.remove();
}
});
marker.bindPopup(p.lat + ', ' + p.lng);
if (broadcast)
ws.send(JSON.stringify(p));
return marker;
}
let wsProto = window.location.protocol == 'https:' ? 'wss' : 'ws';
let pathname = window.location.pathname == '/' ? '' : window.location.pathname;
let ws = new WebSocket(wsProto + '://' + window.location.host + pathname + '/ws');
ws.onopen = e => {
mapDiv.style.pointerEvents = 'all';
};
ws.onmessage = e => {
let markers = JSON.parse(e.data);
if (!markers) return;
for (let ma of markers) {
if (ma.del) {
map.eachLayer(l => {
if (l instanceof L.Marker) {
let p = l.getLatLng();
if (p.lat == ma.lat && p.lng == ma.lng)
l.remove();
}
});
} else {
let marker = newMarker(ma);
marker.addTo(map);
}
}
};
ws.onclose = e => {
alert('Reconnecting...');
window.location.reload();
};
let mapDiv = document.createElement("div");
mapDiv.id = "map";
mapDiv.style.height = window.innerHeight - 20;
mapDiv.style.pointerEvents = 'none';
document.body.appendChild(mapDiv);
let map = L.map('map').setView([41.151580414599984, -8.60984802246094], 8);
map.on('click', e => {
if (confirm('Add marker here?')) {
let marker = newMarker(e.latlng, true);
marker.addTo(map);
}
});
map.on('keypress', e => {
// Ctrl-x
if (e.originalEvent.ctrlKey && e.originalEvent.charCode == 120) {
let txt = prompt('Lat, Lng:');
if (!txt) return;
let coords = txt.split(/,|\|/);
let p = {
lat: parseFloat(coords[0]),
lng: parseFloat(coords[1])
};
if (p.lat && p.lng) {
let marker = newMarker(p, true);
marker.addTo(map);
}
}
});
window.onresize = resizeMap;
let baseLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
maximumAge: 1000 * 5 * 60,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
let wikiLayer = L.tileLayer('https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png ', {
maxZoom: 18,
maximumAge: 1000 * 5 * 60,
attribution: '<a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia</a> maps | Map data &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
});
let elevLayer = L.tileLayer('https://{s}.tile.thunderforest.com/landscape/{z}/{x}/{y}.png?apikey=a5dd6a2f1c934394bce6b0fb077203eb', {
maxZoom: 18,
maximumAge: 1000 * 5 * 60,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
});
let bwLayer = L.tileLayer('http://{s}.tile.stamen.com/toner/{z}/{x}/{y}@2x.png', {
maxZoom: 18,
maximumAge: 1000 * 5 * 60,
});
let bwLayer2 = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png', {
maxZoom: 18,
maximumAge: 1000 * 5 * 60,
attribution: 'Map tiles by Carto, under CC BY 3.0. Data by OpenStreetMap, under ODbL'
});
let trainLayer = L.tileLayer('https://{s}.tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png', {
maxZoom: 18,
maximumAge: 1000 * 5 * 60,
});
L.control.layers({"Base": baseLayer, "Wiki": wikiLayer, "Elevation": elevLayer, "BW": bwLayer, "BW 2": bwLayer2}, {"Trains": trainLayer}).addTo(map);
</script>
</body>
</html>`)
func main() {
listen := os.Args[1]
path := os.Args[2]
db, err := bolt.Open(path, 0600, nil)
if err != nil {
panic(err)
}
defer db.Close()
defer saveDB(db)
gClients = newClients()
gMarkers = newMarkers()
// reload database
err = db.View(func(tx *bolt.Tx) error {
bkt := tx.Bucket([]byte("markers"))
if bkt == nil {
return nil
}
return bkt.ForEach(func(marker, _ []byte) error {
m, err := gobDecode(marker)
if err != nil {
return err
}
gMarkers.Load(m)
return nil
})
})
if err != nil {
panic(err)
}
http.HandleFunc("/", indexHandler)
http.HandleFunc("/ws", wsHandler)
go func() {
err := http.ListenAndServe(listen, nil)
if err != nil {
panic(err)
}
}()
sigs := make(chan os.Signal, 8)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT)
for {
select {
case <-sigs:
return
case <-time.After(30 * time.Second):
saveDB(db)
}
}
}
func saveDB(db *bolt.DB) {
err := db.Update(func(tx *bolt.Tx) error {
bkt, err := tx.CreateBucketIfNotExists([]byte("markers"))
if err != nil {
return err
}
for ma := range gMarkers.AllToBeSavedDB() {
m := ma.ToMarker()
mBytes, err := gobEncode(&m)
if err != nil {
return err
}
if ma.Del {
bkt.Delete(mBytes)
} else {
bkt.Put(mBytes, []byte("OK"))
}
}
return nil
})
if err != nil {
panic(err)
}
}
func indexHandler(w http.ResponseWriter, r *http.Request) {
_, err := w.Write(indexHtml)
if err != nil {
httpError(w, err)
}
}
func wsHandler(w http.ResponseWriter, r *http.Request) {
c, err := websocket.Accept(w, r, websocket.AcceptOptions{InsecureSkipVerify: true})
if err != nil {
httpError(w, err)
return
}
defer c.Close(websocket.StatusNormalClosure, "bye")
// the parent context
ctx := r.Context()
// new client has arrived
gClients.Add(c)
defer gClients.Del(c)
// write all saved up markers
if !writeAction(ctx, c, gMarkers.All()) {
httpError(w, "couldn't write all saved up markers")
return
}
for {
ma, ok := readAction(ctx, c)
if !ok {
// websocket was closed,
// or another error happened
return
}
if ma.Del {
gMarkers.Del(ma.ToMarker())
} else {
gMarkers.Add(ma.ToMarker())
}
go writeActionBroadcast(ctx, c, []MarkerAction{ma})
}
}
func readAction(ctx context.Context, c *websocket.Conn) (ma MarkerAction, ok bool) {
err := wsjson.Read(ctx, c, &ma)
if err != nil {
return
}
ok = true
return
}
func writeAction(ctx context.Context, c *websocket.Conn, mas []MarkerAction) (ok bool) {
err := wsjson.Write(ctx, c, &mas)
if err != nil {
return
}
ok = true
return
}
func writeActionBroadcast(ctx context.Context, c *websocket.Conn, mas []MarkerAction) {
for conn := range gClients.All() {
if c == conn {
// skip ourselves
continue
}
go writeAction(ctx, conn, mas)
}
}
func httpError(w http.ResponseWriter, err interface{}) {
var msg string
switch e := err.(type) {
default:
msg = "<error>"
case error:
msg = e.Error()
case string:
msg = e
}
http.Error(w, msg, 400)
}
func newClients() *Clients {
return &Clients{cli: make(map[*websocket.Conn]struct{})}
}
func (c *Clients) All() <-chan *websocket.Conn {
ch := make(chan *websocket.Conn)
go func() {
c.mux.RLock()
for conn := range c.cli {
ch <- conn
}
c.mux.RUnlock()
close(ch)
}()
return ch
}
func (c *Clients) Add(conn *websocket.Conn) {
c.mux.Lock()
c.cli[conn] = struct{}{}
c.mux.Unlock()
}
func (c *Clients) Del(conn *websocket.Conn) {
c.mux.Lock()
delete(c.cli, conn)
c.mux.Unlock()
}
func (ma *MarkerAction) ToMarker() Marker {
return Marker{ma.Lat, ma.Lng}
}
func (m *Marker) ToAction(del bool) MarkerAction {
return MarkerAction{m.Lat, m.Lng, del}
}
func newMarkers() *Markers {
return &Markers{sto: make(map[Marker]uint8)}
}
func (ms *Markers) All() (actions []MarkerAction) {
ms.mux.RLock()
for m, status := range ms.sto {
if status != markerToBeDeletedDB {
actions = append(actions, m.ToAction(false))
}
}
ms.mux.RUnlock()
return
}
func (ms *Markers) AllToBeSavedDB() <-chan MarkerAction {
ch := make(chan MarkerAction)
go func() {
ms.mux.Lock()
if !ms.dirty {
ms.mux.Unlock()
close(ch)
return
}
for m, status := range ms.sto {
switch status {
case markerToBeSavedDB:
ms.sto[m] = markerSavedDB
ch <- m.ToAction(false)
case markerToBeDeletedDB:
delete(ms.sto, m)
ch <- m.ToAction(true)
}
}
ms.dirty = false
ms.mux.Unlock()
close(ch)
}()
return ch
}
func (ms *Markers) Add(m Marker) {
ms.mux.Lock()
ms.dirty = true
ms.sto[m] = markerToBeSavedDB
ms.mux.Unlock()
}
func (ms *Markers) Load(m Marker) {
ms.mux.Lock()
ms.sto[m] = markerSavedDB
ms.mux.Unlock()
}
func (ms *Markers) Del(m Marker) {
ms.mux.Lock()
ms.dirty = true
ms.sto[m] = markerToBeDeletedDB
ms.mux.Unlock()
}
func gobEncode(m *Marker) ([]byte, error) {
var buf bytes.Buffer
err := gob.NewEncoder(&buf).Encode(m)
return buf.Bytes(), err
}
func gobDecode(data []byte) (m Marker, err error) {
err = gob.NewDecoder(bytes.NewReader(data)).Decode(&m)
return
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment