Last active
November 4, 2021 13:16
-
-
Save sug0/1c114b77f69e4ca335a870ceb5478d56 to your computer and use it in GitHub Desktop.
Collaborative OSM based road trip planner in Go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: '© <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 © <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: '© <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