I had a desire to have a live sonar feed displayed in the control panel. Visual feedback, if you will. At first, I used google charts, and updated it frequently, but it wasn't quite what I was looking for.
The radar chart is not specifically tied to polar coordinates, and it was difficult to accurately represent my sonar scan dataset without fiddling with it too much before sending off to google for charting. And god forbid I should change the scan interval.....
So... I started looking for a reasonable canvas based Sonar/Radar screen... and I looked... and I looked... not much out there... Lots of stuff done in "processing" or various toolsets... but no raw HTML5/Javascript canvas.
But I *did* come across this awesome looking HTML5 Clock that was just what I needed as a base to build upon at http://saturnboy.com/2013/10/html5-sonarcnv-clock
The sonar.php file which displays the instrument expects it's data as a JSON encoded data set in the form of:
{"ardtime":"43942","pan":"25","radius":"117","heading":"38.6"}
- Ardtime is the millis counter sent from the Arduino at the time of start of scan. a UID if you like, to associate a set of data.
- Pan is the angle at which the sonar pod is taking its reading with respect to the front of the rover.
- Radius is the distance to target in Centimeters.
- Heading is the compass direction the rover is pointing.
Currently it clears the face at the end of each sweep. When I get more time, I will be correcting that as well as integrating multiple overlapping scans to filter out noise. Yes... I am already normalizing each scan by averaging five pings per position, but still finding spurious reflections that are messing with the mapping.
Here is the "get_sonar.php" script that retrieves the most recent sonar dataset from MySQL and JSON encodes it for the Javascript that draws the sonar screen.
<?php
if (isset($argv)) { // If running from the command line
$pan = $argv[1];
}
else {
$pan = $_GET['pan'];
}
include 'database_creds.inc';
$limit = 360; // How many results to show in chart
$send_string = '{"success": true,"scan": [';
try {
$dbh = new PDO("mysql:host=$hostname;dbname=mapdb", $username, $password);
/*** echo a message saying we have connected ***/
// echo 'Connected to database<br>';
$sth = $dbh->query('SELECT MAX(uid) FROM scanning'); // Get UID of most recent scan
$maxuid = $sth->fetchColumn();
$sql = 'SELECT ardtime FROM scanning WHERE uid = :maxuid';
$sth = $dbh->prepare($sql);
$sth->bindValue(':maxuid', $maxuid, PDO::PARAM_INT);
$sth->execute();
$row = $sth->fetch(PDO::FETCH_ASSOC);
$ardtime = $row["ardtime"];
$sql = 'SELECT ardtime, pan, radius, heading FROM scanning WHERE ardtime = :ardtime ORDER BY pan';
$sth = $dbh->prepare($sql);
$sth->bindValue(':ardtime', $ardtime, PDO::PARAM_STR);
//$sth->bindValue(':pan', $pan, PDO::PARAM_STR);
$sth->execute();
while ($row = $sth->fetch(PDO::FETCH_ASSOC)) {
$send_string .= json_encode($row) .',';
}
$send_string = rtrim($send_string, ',');
$send_string .= '] }';
echo $send_string;
$dbh = null;
$sth = null;
}
catch(PDOException $e)
{
echo $e->getMessage();
}
?>
And here is the "sonar.php" script that displays the most recent sonar dataset as a sweeping sonar screen.
<!DOCTYPE HTML>
<html>
<head>
<title>Sonar</title>
<style>
#sonarcnv {
position:absolute;
top:50%;
left:50%;
width:400px;
height:400px;
margin:-200px 0 0 -200px;
}
</style>
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.8.2/jquery.min.js" type="text/javascript"></script>
</head>
<body onload="sonar();">
<canvas id="sonarcnv"></canvas>
<script>
// Much of this borrowed from http://saturnboy.com/2013/10/html5-sonarcnv-clock/
//
//
// ******* Global variables *******
var JSONping = ""; // JSON array of the most recent sonar scan
var secAngle = 0; // angle of the sweep hand
var ardtime = 0; // Arduino time (UID) associated with most recent scan
function sonar() {
var sonarcnv = document.getElementById('sonarcnv');
//guarantee sonarcnv is supported
if (!sonarcnv.getContext) {
alert('sonarcnv not supported!');
return;
}
sonarcnv.width = 400;
sonarcnv.height = 400;
var sonarctx = sonarcnv.getContext('2d');
sonarctx.clearRect(0, 0, 400, 400);
drawFace(sonarctx);
tick();
}
function tick() { // Sweep the hand around the dial
secAngle += 40 * Math.PI / 60;
// 40 is an arbitrary number I chose for the speed of the sweep
var sonarcnv = document.getElementById('sonarcnv');
var sonarctx = sonarcnv.getContext('2d');
sonarctx.translate(200, 200);
drawTicks(sonarctx, secAngle);
drawSweepHand(sonarctx, secAngle);
sonarctx.translate(-200, -200);
drawFace2(sonarctx);
drawPings(sonarctx, secAngle); // Draw most recent sonar sweep targets
setTimeout(tick, 15);
}
function drawFace(sonarctx) {
//outer black ring
sonarctx.beginPath();
sonarctx.arc(200, 200, 200, 0, 2 * Math.PI);
sonarctx.fillStyle = '#111';
sonarctx.fill();
sonarctx.closePath();
//next light grey ring
sonarctx.beginPath();
sonarctx.arc(200, 200, 198, 0, 2 * Math.PI);
sonarctx.fillStyle = '#bbb';
sonarctx.fill();
sonarctx.closePath();
//outer gradient bezel
var g1x1 = 200 + 196 * Math.cos(4/3*Math.PI);
var g1y1 = 200 + 196 * Math.sin(4/3*Math.PI)
var g1x2 = 200 + 196 * Math.cos(1/3*Math.PI);
var g1y2 = 200 + 196 * Math.sin(1/3*Math.PI);
var g1 = sonarctx.createLinearGradient(g1x1,g1y1,g1x2,g1y2);
g1.addColorStop(0, '#f4f4f4');
g1.addColorStop(1, '#000');
sonarctx.beginPath();
sonarctx.arc(200, 200, 196, 0, 2 * Math.PI);
sonarctx.fillStyle = g1;
sonarctx.fill();
sonarctx.closePath();
//next outer bezel
var g2x1 = 200 + 174 * Math.cos(4/3*Math.PI);
var g2y1 = 200 + 174 * Math.sin(4/3*Math.PI)
var g2x2 = 200 + 174 * Math.cos(1/3*Math.PI);
var g2y2 = 200 + 174 * Math.sin(1/3*Math.PI);
var g2 = sonarctx.createLinearGradient(g2x1,g2y1,g2x2,g2y2);
g2.addColorStop(0, '#000');
g2.addColorStop(1, '#777');
sonarctx.beginPath();
sonarctx.arc(200, 200, 174, 0, 2 * Math.PI);
sonarctx.fillStyle = g2;
sonarctx.fill();
sonarctx.closePath();
//tan clock face
sonarctx.beginPath();
sonarctx.arc(200, 200, 162, 0, 2 * Math.PI);
//sonarctx.fillStyle = '#d5c595';
sonarctx.fillStyle = '#666666';
sonarctx.fill();
sonarctx.closePath();
sonarctx.beginPath();
sonarctx.arc(200, 200, 38, 0, 2 * Math.PI);
sonarctx.lineWidth = 2;
sonarctx.strokeStyle = '#666666';
sonarctx.stroke();
sonarctx.closePath();
}
function drawTicks(sonarctx, ang) {
//radial lines
drawPath(sonarctx, 'M -98,-1 L 98,-1 98,1 -98,1 Z', '#baa77c', 0);
drawPath(sonarctx, 'M -98,-1 L 98,-1 98,1 -98,1 Z', '#baa77c', 30);
drawPath(sonarctx, 'M -98,-1 L 98,-1 98,1 -98,1 Z', '#baa77c', 60);
drawPath(sonarctx, 'M -98,-1 L 98,-1 98,1 -98,1 Z', '#baa77c', 90);
drawPath(sonarctx, 'M -98,-1 L 98,-1 98,1 -98,1 Z', '#baa77c', 120);
drawPath(sonarctx, 'M -98,-1 L 98,-1 98,1 -98,1 Z', '#baa77c', 150);
//triangle ticks
drawPath(sonarctx, 'M 154,0 L 178,-6 178,6 Z', '#111', 0);
drawPath(sonarctx, 'M 154,0 L 178,-6 178,6 Z', '#111', 90);
drawPath(sonarctx, 'M 154,0 L 178,-6 178,6 Z', '#111', 180);
drawPath(sonarctx, 'M 154,0 L 178,-6 178,6 Z', '#111', -90);
//long brown ticks
drawPath(sonarctx, 'M 156,-2 L 180,-2 180,2 156,2 Z', '#baa77c', 30);
drawPath(sonarctx, 'M 156,-2 L 180,-2 180,2 156,2 Z', '#baa77c', 60);
drawPath(sonarctx, 'M 156,-2 L 180,-2 180,2 156,2 Z', '#baa77c', 120);
drawPath(sonarctx, 'M 156,-2 L 180,-2 180,2 156,2 Z', '#baa77c', 150);
drawPath(sonarctx, 'M 156,-2 L 180,-2 180,2 156,2 Z', '#baa77c', -30);
drawPath(sonarctx, 'M 156,-2 L 180,-2 180,2 156,2 Z', '#baa77c', -60);
drawPath(sonarctx, 'M 156,-2 L 180,-2 180,2 156,2 Z', '#baa77c', -120);
drawPath(sonarctx, 'M 156,-2 L 180,-2 180,2 156,2 Z', '#baa77c', -150);
//text labels: 3,6,9,12
sonarctx.font = '16pt Georgia';
sonarctx.textAlign = 'center';
sonarctx.textBaseline = 'middle';
sonarctx.fillStyle = '#444';
sonarctx.fillText('30', 0, -48);
sonarctx.fillText('75', 0, -82);
sonarctx.fillText('150', 0, -125);
sonarctx.fillText('225', 0, -155);
//big dot above 12
sonarctx.beginPath();
sonarctx.arc(0, -185, 4, 0, 2 * Math.PI);
sonarctx.fillStyle = '#444';
sonarctx.fill();
sonarctx.closePath();
for (var i = 0; i < 360; i += 30) {
//outer tick squares
drawPath(sonarctx, 'M 170,-3 L 174,-3 174,3 170,3 Z', 'rgba(68,68,68,0.8)', i);
//inner tick squares
drawPath(sonarctx, 'M 104,-3 L 110,-3 110,3 104,3 Z', 'rgba(68,68,68,0.8)', i);
//outer text labels
var lbl = '' + Math.round(i +90);
if (lbl == '360') lbl = '';
if (lbl == '390') lbl = '30';
if (lbl == '420') lbl = '60';
var x = 185 * Math.cos(i * Math.PI / 180.0);
var y = 185 * Math.sin(i * Math.PI / 180.0);
sonarctx.save();
sonarctx.translate(x,y);
sonarctx.rotate((i + 90 - (i > 0 && i < 180 ? 180 : 0)) * Math.PI / 180.0);
sonarctx.font = '9pt Georgia';
sonarctx.textAlign = 'center';
sonarctx.textBaseline = 'middle';
sonarctx.fillStyle = 'rgba(68,68,68,0.8)';
sonarctx.fillText(lbl, 0, 0);
sonarctx.restore();
//far outer dots between labels
sonarctx.beginPath();
x = 180 * Math.cos((i+15) * Math.PI / 180);
y = 180 * Math.sin((i+15) * Math.PI / 180);
sonarctx.arc(x, y, 1.5, 0, 2 * Math.PI);
sonarctx.fillStyle = '#444';
sonarctx.fill();
sonarctx.closePath();
for (var j = 0; j < 25; j += 6) {
if (j != 0) {
//outer tick ring - long ticks
drawPath(sonarctx, 'M 154,-0.5 L 164,-0.5 164,0.5 154,0.5 Z', '#444', i+j);
//inner tick ring - short ticks
drawPath(sonarctx, 'M 104,-0.5 L 110,-0.5 110,0.5 104,0.5 Z', 'rgba(68,68,68,0.8)', i+j);
}
for (var k = 1.5; k < 5; k += 1.5) {
//outer tick ring - short ticks
drawPath(sonarctx, 'M 160,-0.3 L 164,-0.3 164,0.3 160,0.3 Z', 'rgba(68,68,68,0.5)', i+j+k);
}
}
}
}
function drawSweepHand(sonarctx,ang) {
drawPath(sonarctx, 'M -50,0 L -45,-5 -25,-5 -22,-2 22,-2 25,-5 160,0 25,5 22,2 -22,2 -25,5 -45,5 Z', '#006666', ang-90);
// the -90 puts North at the top.
sonarctx.beginPath();
sonarctx.arc(0, 0, 8, 0, 2 * Math.PI);
sonarctx.fillStyle = '#008800';
sonarctx.fill();
sonarctx.closePath();
}
function drawFace2(sonarctx) {
//outer center button ring
var g1x1 = 200 + 5 * Math.cos(4/3*Math.PI);
var g1y1 = 200 + 5 * Math.sin(4/3*Math.PI)
var g1x2 = 200 + 5 * Math.cos(1/3*Math.PI);
var g1y2 = 200 + 5 * Math.sin(1/3*Math.PI);
var g1 = sonarctx.createLinearGradient(g1x1,g1y1,g1x2,g1y2);
g1.addColorStop(0, '#999');
g1.addColorStop(1, '#333');
sonarctx.beginPath();
sonarctx.arc(200, 200, 5, 0, 2 * Math.PI);
sonarctx.fillStyle = g1;
sonarctx.fill();
sonarctx.closePath();
//inner center button
var g2x1 = 200 + 3 * Math.cos(4/3*Math.PI);
var g2y1 = 200 + 3 * Math.sin(4/3*Math.PI)
var g2x2 = 200 + 3 * Math.cos(1/3*Math.PI);
var g2y2 = 200 + 3 * Math.sin(1/3*Math.PI);
var g2 = sonarctx.createLinearGradient(g2x1,g2y1,g2x2,g2y2);
g2.addColorStop(0, '#ccc');
g2.addColorStop(1, '#aaa');
sonarctx.beginPath();
sonarctx.arc(200, 200, 3, 0, 2 * Math.PI);
sonarctx.fillStyle = g2;
sonarctx.fill();
sonarctx.closePath();
//highlight (gradient overlay)
var g3x1 = 200 + 162 * Math.cos(4/3*Math.PI);
var g3y1 = 200 + 162 * Math.sin(4/3*Math.PI)
var g3x2 = 200 + 162 * Math.cos(1/3*Math.PI);
var g3y2 = 200 + 162 * Math.sin(1/3*Math.PI);
var g3 = sonarctx.createLinearGradient(g3x1,g3y1,g3x2,g3y2);
g3.addColorStop(0, 'rgba(0,0,0,0.5)');
g3.addColorStop(1, 'rgba(0,0,0,0)');
sonarctx.beginPath();
sonarctx.arc(200, 200, 162, 0, 2 * Math.PI);
sonarctx.fillStyle = g3;
//sonarctx.fillStyle = 'black';
sonarctx.fill();
sonarctx.closePath();
sonarctx.beginPath();
sonarctx.arc(200, 200, 68, 0, 2 * Math.PI);
sonarctx.lineWidth = 2;
sonarctx.strokeStyle = '#666666';
sonarctx.stroke();
sonarctx.closePath();
sonarctx.beginPath();
sonarctx.arc(200, 200, 38, 0, 2 * Math.PI);
sonarctx.lineWidth = 2;
sonarctx.strokeStyle = '#666666';
sonarctx.stroke();
sonarctx.closePath();
}
/** Simple svg path parser that only understands M (move to) and L (line to). */
function drawPath(sonarctx,path,fill,ang) {
sonarctx.save();
sonarctx.rotate(ang == undefined ? 0 : ang * Math.PI / 180.0);
sonarctx.beginPath();
var parts = path.split(' ');
while (parts.length > 0) {
var part = parts.shift();
if (part == 'M') {
coords = parts.shift().split(',');
sonarctx.moveTo(coords[0], coords[1]);
} else if (part == 'L') {
continue;
} else if (part == 'Z') {
break;
} else if (part.indexOf(',') >= 0) {
coords = part.split(',');
sonarctx.lineTo(coords[0], coords[1]);
}
}
sonarctx.closePath();
sonarctx.fillStyle = (fill == undefined ? '#000' : fill);
sonarctx.fill();
sonarctx.restore();
}
function drawPings(sonarctx, ang) { // Draw sonar targets on dial
var x=null;
var y=null;
var oldx =200, oldy=200;
var rang = findReferenceAngle(ang-90);
var pan = null;
var radius = null;
var heading = null;
var surl = null;
var dt = new Date();
var grabbed = 0;
if(rang > 0 && rang < 5 && grabbed === 0) {
grabbed = 1;
$.get('get_sonar.php', function(row){ // Get the most recent sonar sweep to display
JSONping = JSON.parse(row);
});
}
if(dt.getSeconds() > 0) grabbed = 0;
for (var i in JSONping.scan) {
var scan = JSONping.scan[i];
pan = JSONping.scan[i].pan; // Pan position of sonar scan
radius = JSONping.scan[i].radius; // Polar radius
heading = JSONping.scan[i].heading;
ardtime = JSONping.scan[i].heading;
// Red dot for current heading
sonarctx.beginPath();
hx = (156 * Math.cos(toRadians(heading-90))) +200;
hy = (156 * Math.sin(toRadians(heading-90))) + 200;
sonarctx.arc(hx, hy, 5, 0, 2 * Math.PI);
sonarctx.fillStyle = "red";
sonarctx.fill();
sonarctx.closePath();
if(radius > 150) radius = 150; // limit scale to boundary of dial
// Convert polar to cartesian
if((pan) <= findReferenceAngle(ang))
{
x = (radius * Math.cos(toRadians(pan-90))) +200;
y = (radius * Math.sin(toRadians(pan-90))) + 200;
// Draw the ping target at the cartesian coordinate
sonarctx.beginPath();
sonarctx.arc(x, y, 3, 0, 2 * Math.PI);
sonarctx.fillStyle = "green";
sonarctx.fill();
sonarctx.closePath();
sonarctx.strokeStyle = 'green';
sonarctx.moveTo(oldx, oldy);
sonarctx.lineTo(x,y);
oldx = x; oldy = y;
sonarctx.stroke();
}
}
}
function toDegrees (angle) {
return angle * (180 / Math.PI);
}
function toRadians (angle) {
return angle * (Math.PI / 180);
}
function findReferenceAngle(ang) {
var quad = "";
var ref = "No";
while (ref === "No"){
if (ang >= 0 && ang < 360){
ref = ang;
} else if (ang < 0){
ang = ang + 360;
} else {
ang = ang - 360;
}
}
return ang;
}
function print_r(theObj){
if(theObj.constructor == Array ||
theObj.constructor == Object){
document.write("<ul>")
for(var p in theObj){
if(theObj[p].constructor == Array||
theObj[p].constructor == Object){
document.write("<li>["+p+"] => "+typeof(theObj)+"</li>");
document.write("<ul>")
print_r(theObj[p]);
document.write("</ul>")
} else {
document.write("<li>["+p+"] => "+theObj[p]+"</li>");
}
}
document.write("</ul>")
}
}
</script>
</body>
</html>
and just for completion, here is what a live dataset, JSON encoded from MYSQL would look like:
{"success": true,"scan": [
{"ardtime":"134","pan":"5","radius":"63","heading":"19.5"},{"ardtime":"134","pan":"10","radius":"74","heading":"19.5"},{"ardtime":"134","pan":"15","radius":"73","heading":"19.5"},{"ardtime":"134","pan":"20","radius":"76","heading":"19.5"},{"ardtime":"134","pan":"25","radius":"69","heading":"19.5"},{"ardtime":"134","pan":"30","radius":"61","heading":"19.5"},{"ardtime":"134","pan":"35","radius":"67","heading":"19.5"},{"ardtime":"134","pan":"40","radius":"61","heading":"19.5"},{"ardtime":"134","pan":"45","radius":"53","heading":"19.5"},{"ardtime":"134","pan":"50","radius":"54","heading":"19.5"},{"ardtime":"134","pan":"55","radius":"43","heading":"19.5"},{"ardtime":"134","pan":"60","radius":"53","heading":"19.5"},{"ardtime":"134","pan":"65","radius":"54","heading":"19.5"},{"ardtime":"134","pan":"70","radius":"56","heading":"19.5"},{"ardtime":"134","pan":"75","radius":"56","heading":"19.5"},{"ardtime":"134","pan":"80","radius":"41","heading":"19.5"},{"ardtime":"134","pan":"85","radius":"58","heading":"19.5"},{"ardtime":"134","pan":"95","radius":"61","heading":"19.6"},{"ardtime":"134","pan":"100","radius":"40","heading":"19.6"},{"ardtime":"134","pan":"105","radius":"50","heading":"19.6"},{"ardtime":"134","pan":"110","radius":"58","heading":"19.6"},{"ardtime":"134","pan":"115","radius":"48","heading":"19.6"},{"ardtime":"134","pan":"120","radius":"59","heading":"19.6"},{"ardtime":"134","pan":"125","radius":"87","heading":"19.6"},{"ardtime":"134","pan":"130","radius":"66","heading":"19.6"},{"ardtime":"134","pan":"135","radius":"75","heading":"19.6"},{"ardtime":"134","pan":"140","radius":"85","heading":"19.6"},{"ardtime":"134","pan":"145","radius":"97","heading":"19.6"},{"ardtime":"134","pan":"150","radius":"104","heading":"19.6"},{"ardtime":"134","pan":"155","radius":"68","heading":"19.6"},{"ardtime":"134","pan":"160","radius":"98","heading":"19.6"},{"ardtime":"134","pan":"165","radius":"102","heading":"19.6"},{"ardtime":"134","pan":"170","radius":"80","heading":"19.6"},{"ardtime":"134","pan":"175","radius":"86","heading":"19.6"},{"ardtime":"134","pan":"180","radius":"102","heading":"19.5"},{"ardtime":"134","pan":"185","radius":"81","heading":"19.5"},{"ardtime":"134","pan":"190","radius":"101","heading":"19.5"},{"ardtime":"134","pan":"195","radius":"98","heading":"19.5"},{"ardtime":"134","pan":"200","radius":"106","heading":"19.5"},{"ardtime":"134","pan":"205","radius":"69","heading":"19.5"},{"ardtime":"134","pan":"210","radius":"112","heading":"19.5"},{"ardtime":"134","pan":"215","radius":"68","heading":"19.5"},{"ardtime":"134","pan":"220","radius":"49","heading":"19.5"},{"ardtime":"134","pan":"225","radius":"71","heading":"19.5"},{"ardtime":"134","pan":"230","radius":"36","heading":"19.5"},{"ardtime":"134","pan":"235","radius":"30","heading":"19.5"},{"ardtime":"134","pan":"240","radius":"41","heading":"19.5"},{"ardtime":"134","pan":"245","radius":"40","heading":"19.5"},{"ardtime":"134","pan":"250","radius":"35","heading":"19.5"},{"ardtime":"134","pan":"255","radius":"46","heading":"19.5"},{"ardtime":"134","pan":"260","radius":"42","heading":"19.5"},{"ardtime":"134","pan":"265","radius":"53","heading":"19.5"},{"ardtime":"134","pan":"275","radius":"80","heading":"19.6"},{"ardtime":"134","pan":"280","radius":"59","heading":"19.6"},{"ardtime":"134","pan":"285","radius":"70","heading":"19.6"},{"ardtime":"134","pan":"290","radius":"74","heading":"19.6"},{"ardtime":"134","pan":"295","radius":"61","heading":"19.6"},{"ardtime":"134","pan":"300","radius":"74","heading":"19.6"},{"ardtime":"134","pan":"305","radius":"76","heading":"19.6"},{"ardtime":"134","pan":"310","radius":"70","heading":"19.6"},{"ardtime":"134","pan":"315","radius":"89","heading":"19.6"},{"ardtime":"134","pan":"320","radius":"93","heading":"19.6"},{"ardtime":"134","pan":"325","radius":"52","heading":"19.6"},{"ardtime":"134","pan":"330","radius":"77","heading":"19.6"},{"ardtime":"134","pan":"335","radius":"105","heading":"19.6"},{"ardtime":"134","pan":"340","radius":"65","heading":"19.6"},{"ardtime":"134","pan":"345","radius":"91","heading":"19.6"},{"ardtime":"134","pan":"350","radius":"88","heading":"19.6"},{"ardtime":"134","pan":"355","radius":"77","heading":"19.6"},{"ardtime":"134","pan":"360","radius":"74","heading":"19.5"}
] }