Plot vessels on a map
A Node-Red flow to plot recently-seen vessels on a map
Introduction
The automatic identification system (AIS) is an automatic tracking system that uses transponders on ships. This flow accepts information from an AIS receiver, stores it in a database, and displays a web page showing the names and positions of the most recently-seen vessels.
Install
These instructions assume you are running node-red on a Raspberry Pi, and are
logged on using the pi
username. They assume you are using rtl_ais
and that it
is installed and working, receiving AIS messages and sending them to port 10110
using UDP.
Ensure that the database system Sqlite3
is installed using
sudo apt-get install sqlite3
Make sure you are in the home directory /home/pi
.
Create a suitable empty database called ships.db
by copying the below
commands and pasting them into your terminal session:
sqlite3 ships.db
CREATE TABLE ships (name varchar(80), mmsi varchar(9) unique, type integer, dest);
CREATE TABLE tracks (mmsi varchar(9), lat real, long real, course real optional, speed real optional, time integer);
CREATE TABLE last (lastlat real, lastlong real, lastspeed real, lastcourse real, lasttime integer, mmsi varchar(9) unique);
.quit
You now need to install the node-red modules which this flow uses. They are
node-red-contrib-ais-decoder
, node-red-contrib-web-worldmap
and
node-red-node-sqlite
. Then import and deploy the flow.
Usage
To see the result, visit http://your.pis.ip.address:1880/main. The left side of the screen contains a list of recently-seen vessels -initially this will be empty, but as time goes on your AIS receiver will pick up more vessels and store them in the database. The list is ordered with the most recently-seen vessel at the top. The longer it is since a vessel was seen, the darker it will appear in the list. Click on a name to see the last-known location. Right-click on the marker to see more information about the vessel.
[{"id":"248b38fb99b5cdfa","type":"tab","label":"AIS 1","disabled":false,"info":""},{"id":"979239a83064a3f4","type":"http in","z":"248b38fb99b5cdfa","name":"","url":"/main","method":"get","upload":false,"swaggerDoc":"","x":100,"y":280,"wires":[["57354988426377a5"]],"info":"This is the main entry point for the web interface to the data. Point your browser to `http://your.own.ip.address/main` to show the most recent ship names and a map to display them on."},{"id":"57354988426377a5","type":"template","z":"248b38fb99b5cdfa","name":"Main HTML","field":"payload","fieldType":"msg","format":"handlebars","syntax":"mustache","template":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<style>\nbody {\n font-family: \"Lato\", sans-serif;\n margin-left: 1px; \n}\n\n/* Fixed sidenav, full height */\n.sidenav {\n height: 100%;\n width: 200px;\n position: fixed;\n z-index: 1;\n top: 0;\n left: 0;\n background-color: #111;\n overflow-x: hidden;\n padding-top: 20px;\n padding-right: 0px;\n}\n\n/* Style the ship button */\n.ship-btn {\n position: relative;\n padding: 6px 8px 6px 16px;\n text-decoration: none;\n font-size: 20px;\n color: #818181;\n display: block;\n border: none;\n background: none;\n width: 100%;\n text-align: left;\n cursor: pointer;\n outline: none;\n}\n\n/* Tooltip text */\n .ship-btn .tooltiptext {\n visibility: hidden;\n background-color: white;\n color: #111;\n text-align: left;\n padding: 5px 5px;\n font-size: 12px;\n border-radius: 3px;\n /* Position the tooltip text */\n position: absolute;\n z-index: 1;\n width: 100px;\n top: 100%;\n left: 50%; \n margin-left: -40px;\n}\n\n/* On mouse-over */\n.ship-btn:hover {\n color: White;\n}\n\n/* Show the tooltip text when you mouse over the tooltip container */\n.ship-btn:hover .tooltiptext {\n visibility: visible;\n}\n\n.second {\n color: Beige;\n}\n\n.minute {\n color: BurlyWood;\n}\n\n.hour {\n color: Peru;\n}\n\n.day {\n color: Brown;\n}\n\n/* Main content */\n.main {\n margin-left: 200px; /* Same as the width of the sidenav */\n padding: 0px 0px;\n height: 100%;\n width: 100%;\n border-style: none;\n position: fixed;\n top: 0;\n left: 200;\n}\n\n/* Add an active class to the active ship button */\n.active {\n background-color: green;\n color: white;\n}\n\n/* Some media queries for responsiveness */\n@media screen and (max-height: 450px) {\n .sidenav {padding-top: 15px;}\n .sidenav a {font-size: 18px;}\n}\n\n</style>\n<script>\n\nfunction friendlyTime(tNow,tOld) {\n var t = tNow - tOld;\n var f = {};\n if (t<60) {\n f.unit = \"second\";\n f.value = t.toFixed(0);\n }\n else if (t<3600) {\n f.unit = \"minute\";\n f.value = Math.round(t/60);\n }\n else if (t<86400) {\n f.unit = \"hour\";\n f.value = Math.round(t/3600);\n }\n else {\n f.unit = \"day\";\n f.value = Math.round(t/86400);\n }\n f.text = f.value + \" \" + f.unit;\n if (f.value>1) f.text += \"s\";\n return f;\n}\n\nasync function clickShip() {\n let ship = document.getElementsByClassName(\"ship-btn\");\n for (let i = 0; i < ship.length; i++) {\n ship[i].classList.remove(\"active\");\n }\n clearMap();\n this.classList.add(\"active\");\n showOnMap(this.id);\n}\n\nasync function clearMap() {\n let recent = await fetch(\"clear\");\n let result = await recent.text();\n}\n\nasync function showOnMap(mmsi) {\n let url = \"show?mmsi=\"+mmsi;\n let recent = await fetch(url);\n let result = await recent.text();\n}\n\nasync function getRecentShips() {\n document.getElementById(\"side\").innerHTML = \"\";\n let recent = await fetch(\"recent\");\n let result = await recent.text();\n let recentShips = JSON.parse(result);\n let sb = \"\";\n let d = new Date();\n let tNow = d.getTime()/1000;\n for (var i=0;i<recentShips.length;i++) {\n var f = friendlyTime(tNow,recentShips[i].time);\n sb += \"<button class=\\\"ship-btn \"+f.unit+\"\\\" id=\\\"\"+recentShips[i].mmsi+\"\\\">\"+recentShips[i].name;\n sb += \"<span class=\\\"tooltiptext\\\">\"+f.text+\" ago</span>\";\n sb += \"</button>\\n\";\n }\n document.getElementById(\"side\").innerHTML = sb;\n /* Loop through all ships to hook the click */\n let ship = document.getElementsByClassName(\"ship-btn\");\n for (i = 0; i < ship.length; i++) {\n ship[i].addEventListener(\"click\", clickShip);\n }\n setTimeout(getRecentShips, 60000);\n}\n \nfunction adjustMapSize() {\n document.getElementById(\"mapframe\").height = window.innerHeight; \n document.getElementById(\"mapframe\").width = window.innerWidth-200;\n}\n\n</script>\n</head>\n\n<body onload=\"getRecentShips()\" onresize=\"adjustMapSize()\">\n\n<div id=\"side\" class=\"sidenav\">\n</div>\n\n<div class=\"main\">\n\n<iframe id=\"mapframe\" src=\"worldmap\" style=\"border:none;\" name=\"iframe_a\" title=\"Map of ship positions\"></iframe>\n\n<script>\nadjustMapSize();\n</script>\n\n</div>\n\n</body>\n</html>\n","output":"str","x":270,"y":280,"wires":[["9c4fe037c51605dd"]],"info":"The main HTML content of the web interface. The scripts in this web page call on web services (within this flow) with entry points `clear`, `show` and `recent`."},{"id":"9c4fe037c51605dd","type":"http response","z":"248b38fb99b5cdfa","name":"","statusCode":"","headers":{},"x":430,"y":280,"wires":[],"info":"Sends the web page to the client."},{"id":"1a0aa9a4465875d2","type":"http in","z":"248b38fb99b5cdfa","name":"","url":"/recent","method":"get","upload":false,"swaggerDoc":"","x":110,"y":560,"wires":[["f5f4b2be893df1ae"]],"info":"Get information about recently-seen vessels."},{"id":"f5f4b2be893df1ae","type":"sqlite","z":"248b38fb99b5cdfa","mydb":"295fca0c.9af166","sqlquery":"fixed","sql":"select distinct\n last.mmsi as mmsi, last.lasttime as time, lastspeed as speed,\n lastlat as lat, lastlong as long,\n lastcourse as course, ships.name as name, ships.type as shiptype, ships.dest as destination\nfrom last, ships\nwhere (ships.mmsi is last.mmsi)\nand (last.lasttime > strftime('%s','now')-3600*24*2)\norder by last.lasttime DESC LIMIT 20;","name":"Recent ships","x":270,"y":560,"wires":[["2f3a50729451343c"]],"info":"Query the database for information about \"recent\" vessels (seen in the last 2 days). The SQL statement can be adjusted to request a shorter or longer interpretation of \"recent\"."},{"id":"2f3a50729451343c","type":"http response","z":"248b38fb99b5cdfa","name":"","statusCode":"","headers":{},"x":430,"y":560,"wires":[],"info":"Sends the result of the database query to the client in JSON format."},{"id":"c254eecbe560e378","type":"http in","z":"248b38fb99b5cdfa","name":"","url":"/show","method":"get","upload":false,"swaggerDoc":"","x":100,"y":460,"wires":[["34bbdaba108ce7af","ce4b7bda8fa5d1fb"]],"info":"Show a specific vessel on the map. One query parameter expected, which is the mmsi of the vessel requested."},{"id":"34bbdaba108ce7af","type":"http response","z":"248b38fb99b5cdfa","name":"","statusCode":"","headers":{},"x":250,"y":500,"wires":[],"info":"Returns an empty response to the client."},{"id":"3a6e5ec32ec22f8f","type":"worldmap","z":"248b38fb99b5cdfa","name":"Ships map","lat":"","lon":"","zoom":"14","layer":"","cluster":"","maxage":"","usermenu":"show","layers":"show","panit":"false","panlock":"false","zoomlock":"false","hiderightclick":"false","coords":"none","showgrid":"false","allowFileDrop":"false","path":"/worldmap","mapname":"","mapurl":"","mapopt":"","mapwms":false,"x":1010,"y":360,"wires":[],"info":"This node displays the map and the ship markers on the web page."},{"id":"4ae065166a750319","type":"sqlite","z":"248b38fb99b5cdfa","mydb":"295fca0c.9af166","sqlquery":"prepared","sql":"select distinct\n last.mmsi as mmsi,\n last.lasttime as time,\n lastspeed as speed,\n lastlat as lat,\n lastlong as lon,\n lastcourse as heading,\n ships.name as name,\n ships.type as shiptype,\n ships.dest as destination\n from last, ships\n where (ships.mmsi is last.mmsi)\n and (ships.mmsi is $mmsi);","name":"Ship data from MMSI","x":500,"y":460,"wires":[["e4daf6f94161df9f"]],"info":"Fetch the information about the vessel from the database."},{"id":"e4daf6f94161df9f","type":"function","z":"248b38fb99b5cdfa","name":"Set up map marker","func":"// Database result comes in an array - make it an object\nlet row = msg.payload[0];\nmsg.payload = row;\n// Do we need to re-centre the map?\nif (!positionWithinBounds(msg.payload.lat,msg.payload.lon)) {\n // Centre the map on the ship\n let c = { \"payload\" : { \"command\" : { \"zoom\" : 14 } } };\n c.payload.command.lat = msg.payload.lat;\n c.payload.command.lon = msg.payload.lon;\n node.send(c);\n}\n// Set attributes for this ship\nmsg.payload.layer = \"ships\";\nmsg.payload.icon = \"ship\";\nmsg.payload.iconColor = shipColour(msg.payload.shiptype);\n// Set the popup\nmsg.payload.contextmenu = \"\";\nmsg.payload.popup = `MMSI: ${msg.payload.mmsi}<br>\nType: ${textVesselType(msg.payload.shiptype)}<br>\nSpeed: ${msg.payload.speed}kt<br>\nHeading: ${compassPoint(msg.payload.heading)}<br>\nDestination: ${msg.payload.destination}<br>\nLast seen: ${friendlyTime(msg.payload.time)} ago`;\n// Done! Send it to the map.\nreturn msg;\n\n// Return the icon colour for a given ship type\nfunction shipColour(t) {\n if (t>0 && t<20) return \"Gray\";\n if (t>=20 && t<30) return \"Pink\";\n if (t==30) return \"LightSalmon\";\n if (t==31 || t==32) return \"LightCoral\";\n if (t==33) return \"DimGray\";\n if (t==34) return \"Indigo\";\n if (t==35) return \"Khaki\";\n if (t==36) return \"IndianRed\";\n if (t==37) return \"HotPink\";\n if (t==38 || t==39) return \"Gray\";\n if (t>=40 && t<50) return \"OrangeRed\";\n if (t>=50 && t<60) return \"SeaGreen\";\n if (t>=60 && t<70) return \"Blue\";\n if (t>=70 && t<80) return \"SandyBrown\";\n if (t>=80 && t<90) return \"Red\";\n return \"Gray\";\n}\n\n// Is the ship position within the bounds of the map?\nfunction positionWithinBounds(lat,lon) {\n let b = flow.get(\"mapBounds\");\n if (lat>b.north || lat<b.south) return false;\n if (lon>b.east || lon<b.west) return false;\n return true;\n}\n\n// Return user-friendly string for the time\nfunction friendlyTime(tOld) {\n let d = new Date();\n let tNow = d.getTime()/1000;\n let t = tNow - tOld;\n let f = {};\n if (t<60) {\n f.unit = \"second\";\n f.value = t.toFixed(0);\n }\n else if (t<3600) {\n f.unit = \"minute\";\n f.value = Math.round(t/60);\n }\n else if (t<86400) {\n f.unit = \"hour\";\n f.value = Math.round(t/3600);\n }\n else {\n f.unit = \"day\";\n f.value = Math.round(t/86400);\n }\n f.text = f.value + \" \" + f.unit;\n if (f.value>1) f.text += \"s\";\n return f.text;\n}\n\n// Return the compass point of a bearing\nfunction compassPoint(b) {\n if (b>337.5) return \"N\";\n if (b>292.5) return \"NW\";\n if (b>247.5) return \"W\";\n if (b>202.5) return \"SW\";\n if (b>157.5) return \"S\";\n if (b>112.5) return \"SE\";\n if (b>67.5) return \"E\";\n if (b>22.5) return \"NE\";\n return \"N\";\n}\n\n// Explanation of ship type\nfunction textVesselType(t){\n const unexpected = \"unexpected\";\n const reserved = \"reserved\";\n const shipType_enum = [\n unexpected, // 0\n reserved, // 1\n reserved,\n reserved,\n reserved,\n reserved,\n reserved,\n reserved,\n reserved,\n reserved,\n reserved,\n reserved,\n reserved,\n reserved,\n reserved,\n reserved,\n reserved,\n reserved,\n reserved,\n reserved, //19\n \"Wing in ground\", //20\n \"Wing in ground, Hazardous category A\",\n \"Wing in ground, Hazardous category B\",\n \"Wing in ground, Hazardous category C\",\n \"Wing in ground, Hazardous category D\",\n \"Wing in ground\",\n \"Wing in ground\",\n \"Wing in ground\",\n \"Wing in ground\",\n \"Wing in ground\", //29\n \"Fishing\",\n \"Towing\",\n \"Towing: length exceeds 200m or breadth exceeds 25m\",\n \"Dredging or underwater operations\",\n \"Diving operations\",\n \"Military operations\",\n \"Sailing\",\n \"Pleasure craft\",\n reserved,\n reserved, //39\n \"High speed craft\", //40\n \"High speed craft, Hazardous category A\",\n \"High speed craft, Hazardous category B\",\n \"High speed craft, Hazardous category C\",\n \"High speed craft, Hazardous category D\",\n \"High speed craft\",\n \"High speed craft\",\n \"High speed craft\",\n \"High speed craft\",\n \"High speed craft\",\n \"Pilot vessel\", //50\n \"Search and rescue vessel\",\n \"Tug\",\n \"Port tender\",\n \"Anti-pollution equipment\",\n \"Law enforcement\",\n \"Spare - Local Vessel\",\n \"Spare - Local Vessel\",\n \"Medical transport\",\n \"Noncombatant ship according to RR Resolution No. 18\",\n \"Passenger\", //60\n \"Passenger, Hazardous category A\",\n \"Passenger, Hazardous category B\",\n \"Passenger, Hazardous category C\",\n \"Passenger, Hazardous category D\",\n \"Passenger\",\n \"Passenger\",\n \"Passenger\",\n \"Passenger\",\n \"Passenger\",\n \"Cargo\", //70\n \"Cargo, Hazardous category A\",\n \"Cargo, Hazardous category B\",\n \"Cargo, Hazardous category C\",\n \"Cargo, Hazardous category D\",\n \"Cargo\",\n \"Cargo\",\n \"Cargo\",\n \"Cargo\",\n \"Cargo\",\n \"Tanker\", //80\n \"Tanker, Hazardous category A\",\n \"Tanker, Hazardous category B\",\n \"Tanker, Hazardous category C\",\n \"Tanker, Hazardous category D\",\n \"Tanker\",\n \"Tanker\",\n \"Tanker\",\n \"Tanker\",\n \"Tanker\",\n \"Other\", //90\n \"Other, Hazardous category A\",\n \"Other, Hazardous category B\",\n \"Other, Hazardous category C\",\n \"Other, Hazardous category D\",\n \"Other\",\n \"Other\",\n \"Other\",\n \"Other\",\n \"Other\",\n ];\n if (t==null) return \"\";\n if (t<shipType_enum.length) return shipType_enum[t] + \" (\"+ t +\")\";\n return \"unknown (\"+t+\")\";\n}\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":730,"y":460,"wires":[["3a6e5ec32ec22f8f"]],"info":"Prepare marker information for the map."},{"id":"a9681a74d896640a","type":"http in","z":"248b38fb99b5cdfa","name":"","url":"/clear","method":"get","upload":false,"swaggerDoc":"","x":100,"y":360,"wires":[["0105ddf48a2cb0d4","da0b6eddeee817f4"]],"info":"Clear markers from the map."},{"id":"0105ddf48a2cb0d4","type":"http response","z":"248b38fb99b5cdfa","name":"","statusCode":"","headers":{},"x":250,"y":400,"wires":[],"info":"Returns an empty response to the client."},{"id":"da0b6eddeee817f4","type":"change","z":"248b38fb99b5cdfa","name":"","rules":[{"t":"set","p":"payload","pt":"msg","to":"{\"command\":{\"clear\":\"ships\"}}","tot":"json"}],"action":"","property":"","from":"","to":"","reg":false,"x":280,"y":360,"wires":[["3a6e5ec32ec22f8f"]],"info":"Construct and issue a command to the map which clears all existing markers from the _ships_ layer."},{"id":"0c411722754e0dbc","type":"worldmap in","z":"248b38fb99b5cdfa","name":"","path":"/worldmap","events":"all","x":100,"y":640,"wires":[["a02e62957fabef4b"]],"info":"Receives information from the map."},{"id":"8e727f5487e63696","type":"udp in","z":"248b38fb99b5cdfa","name":"rtl-ais","iface":"","port":"10110","ipv":"udp4","multicast":"false","group":"","datatype":"utf8","x":90,"y":120,"wires":[["89185eed82c33228"]],"info":"Receives AIS messages from the rtl_ais software."},{"id":"5a2d093dee616b68","type":"ais-decoder","z":"248b38fb99b5cdfa","name":"","x":370,"y":120,"wires":[["48742ecc1c10b071"]],"info":"Decodes the AIS messages into individual variables."},{"id":"89185eed82c33228","type":"split","z":"248b38fb99b5cdfa","name":"","splt":"\\r","spltType":"str","arraySplt":1,"arraySpltType":"len","stream":false,"addname":"","x":230,"y":120,"wires":[["5a2d093dee616b68"]],"info":"If the datagram from rtl-ais contains multiple AIS message fragments, split them into individual node-red messages."},{"id":"38e70872b6a823f2","type":"sqlite","z":"248b38fb99b5cdfa","mydb":"295fca0c.9af166","sqlquery":"batch","sql":"","name":"Ships.db","x":740,"y":120,"wires":[[]],"info":"Execute the input SQL statements as a batch against the database."},{"id":"48742ecc1c10b071","type":"function","z":"248b38fb99b5cdfa","name":"Update database","func":"msg.topic = \"\";\n\n// Update \"last\" table for relevant message types\n\nrelevantMessages = [1,2,3,9,18,19];\nif (relevantMessages.indexOf(msg.payload.messageType)>=0) {\n msg.topic += \"insert or replace into last (mmsi\";\n if (msg.payload.speedOverGround!==undefined) msg.topic += \",lastspeed\";\n if (msg.payload.courseOverGround!==undefined) msg.topic += \",lastcourse\";\n if (msg.payload.latitude!==undefined && msg.payload.longitude!==undefined) {\n msg.topic += \",lastlat,lastlong\";\n }\n msg.topic += \",lasttime) values (\\\"\" + msg.payload.senderMmsi + \"\\\"\";\n if (msg.payload.speedOverGround!==undefined) msg.topic += \",\" + msg.payload.speedOverGround;\n if (msg.payload.courseOverGround!==undefined) msg.topic += \",\" + msg.payload.courseOverGround;\n if (msg.payload.latitude!==undefined && msg.payload.longitude!==undefined) {\n msg.topic += \",\" + msg.payload.latitude + \",\" + msg.payload.longitude;\n }\n msg.topic += \", strftime('%s','now'));\\n\";\n}\n\n// Update \"ships\" table\n\nif (msg.payload.name) {\n var s1 = \"insert into ships (mmsi,name\";\n var s2 = \") values (\\\"\" + msg.payload.senderMmsi + \"\\\",\\\"\"+ msg.payload.name + \"\\\"\";\n var s3 = \") on conflict (mmsi) do update set name=\\\"\" + msg.payload.name + \"\\\"\";\n var s4 = \" where mmsi is \\\"\" + msg.payload.senderMmsi + \"\\\";\\n\";\n if (msg.payload.shipType) {\n s1 += \",type\";\n s2 += \",\" + msg.payload.shipType;\n s3 += \", type=\" + msg.payload.shipType;\n }\n if (msg.payload.destination) {\n s1 += \",dest\";\n s2 += \",\\\"\" + msg.payload.destination +\"\\\"\";\n s3 += \", dest=\\\"\" + msg.payload.destination +\"\\\"\";\n }\n msg.topic += s1+s2+s3+s4;\n}\n\n// Update \"tracks\" table\n\nif (msg.payload.latitude && msg.payload.longitude) {\n var course = msg.payload.courseOverGround;\n if (course===undefined) course = \"NULL\";\n var speed = msg.payload.speedOverGround;\n if (speed===undefined) speed = \"NULL\";\n msg.topic += \"insert into tracks (mmsi, lat, long, course, speed, time) values (\\\"\" +\n msg.payload.senderMmsi + \"\\\", \" +\n msg.payload.latitude + \", \" +\n msg.payload.longitude + \", \" +\n course + \", \" +\n speed + \", strftime('%s','now'));\\n\";\n}\n\n// Only pass the message on if there is some SQL to execute\nif (msg.topic) return msg;\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":550,"y":120,"wires":[["38e70872b6a823f2"]],"info":"Constructs a (sequence of) SQL commands to update the database tables. Note that if the input message shows that a decode error has occurred, no SQL statements will be constructed and no message will be passed on to the database."},{"id":"0161b25dd0ad0f1f","type":"inject","z":"248b38fb99b5cdfa","name":"Purge DB","props":[],"repeat":"","crontab":"00 01 * * 0","once":false,"onceDelay":0.1,"topic":"","x":110,"y":200,"wires":[["d33974ce5118a352"]],"info":"Every so often this node emits a message which causes the subsequent node to delete old tracks from the database. The interval is configurable; as published, it happens every Sunday morning at 01:00.\nOr you can press the button on the node to delete old tracks right away."},{"id":"d33974ce5118a352","type":"sqlite","z":"248b38fb99b5cdfa","mydb":"295fca0c.9af166","sqlquery":"fixed","sql":"delete from tracks where\ntracks.time < strftime('%s','now')-86400*28;","name":"Delete old tracks","x":310,"y":200,"wires":[[]],"info":"Delete old entries from the _tracks_ table in the database. As published, tracks older than 28 days will be deleted, but the SQL statement can be edited if you need to change that limit."},{"id":"dfd87707fe88564a","type":"change","z":"248b38fb99b5cdfa","name":"","rules":[{"t":"set","p":"mapBounds","pt":"flow","to":"payload","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":440,"y":640,"wires":[[]],"info":"Record the map's boundaries (N,S,E and W) in the flow-level context, for use elsewhere in the flow."},{"id":"a02e62957fabef4b","type":"switch","z":"248b38fb99b5cdfa","name":"","property":"payload.action","propertyType":"msg","rules":[{"t":"eq","v":"bounds","vt":"str"}],"checkall":"true","repair":false,"outputs":1,"x":250,"y":640,"wires":[["dfd87707fe88564a"]],"info":"Select only the interesting notifications."},{"id":"ce4b7bda8fa5d1fb","type":"change","z":"248b38fb99b5cdfa","name":"","rules":[{"t":"set","p":"params","pt":"msg","to":"{ \"$mmsi\" : msg.payload.mmsi }","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":280,"y":460,"wires":[["4ae065166a750319"]],"info":"Put the mmsi query parameter into the format expected by the database."},{"id":"c5619d68195ccb6d","type":"comment","z":"248b38fb99b5cdfa","name":"node-red-contrib-flow-ais1","info":"This flow reads data from `rtl_ais`, stores it in a database, and displays a web page showing a list of the most recent ships seen and a map of their locations.","x":150,"y":40,"wires":[]},{"id":"295fca0c.9af166","type":"sqlitedb","db":"/home/pi/ships.db","mode":"RWC"}]