Showing posts with label jQuery. Show all posts
Showing posts with label jQuery. Show all posts

Friday, May 15, 2020

Old Phone/Tablet as an Info Board: Update 2 - The snappy client-side bus board updates

Last time I promised to touch upon what I did on the client side of the bus widget to make it look like this:

In simple terms, I needed to solve the following problem: given the timetable loaded X minutes ago, find the number of minutes remaining until the bus departures now, and based on that, highlight the buses that are convenient to catch (i.e. which depart roughly when I reach the bus stop if I leave soon).

We remember that the timetable, as loaded, returns its output nicely compartmentalized into distinct <span>'s:


so the idea is to do the following at regular intervals:

  • query all #bustime's for departure times;
  • determine the time difference between those times and the current time
  • update the number of minutes in #busreal's
  • re-colour all #bus* according to the number of minutes left, taking into account the time it takes to walk to the bus stop.
The ideal point of insertion from the point of view of the flow control would be the clock() function, which is scheduled to run every second to update the clock; every 10 seconds I ask it to do the following: 

function clock(){
var now = new Date(); var h=now.getHours(); var m=now.getMinutes(); var s = now.getSeconds();
$("#nowclock").text(((h>=10)?h:("0"+h)) + ":" + ((m>=10)?m:("0"+m)) + ":" + ((s>=10)?s:("0"+s)));
if (s % 10 ==0 ) {
var mintogo = 6; // walking distance to stop in minutes
var togo = -1; var nextgo = -1; var num=0; var fnum=-1;
var dom = $("iframe#miway").contents();
while(num<6) {
 var token = "#bustime"+num;
 var nextbus = dom.find(token).text().trim();
 nextbus.replace("now","0 min");
 var am = (nextbus.indexOf('am')>0); var pm = (nextbus.indexOf('pm')>0)
 var idx = nextbus.indexOf(' ');
 if (idx>0) nextbus = nextbus.substr(0,idx);
 var ddot = nextbus.indexOf(":"); if (ddot<0) return;
 var hh = parseInt(nextbus.substr(0,ddot)); var mm = parseInt(nextbus.substr(1+ddot,nextbus.length-ddot));
 if (am && hh==12) hh=0; if (pm && hh<12) hh+=12;
 var hcarry=(h<20 && hh>20); var hhcarry=(h>20 && hh<20);
 if (hcarry) h+=24; if (hhcarry) hh+=24; 
 var diffmin = (mm+hh*60) - (m+1+h*60);
 togo = diffmin - mintogo;
 var thiscol = buscolor(togo);
 var token = "#busnum"+num; if (num==0) token="#bustoken";
 dom.find(token).css("background", thiscol);
 var mintxt = ':'+diffmin+'m';
 if (diffmin<0) mintxt = ':-';
 if (diffmin==0) mintxt = ':now';
 if (num==0) {
   mintxt = '('+diffmin+' min)';
   if (diffmin<0) mintxt = '(--)';
   if (diffmin==0) mintxt = '(now)';
   }
 dom.find("#bustime"+num).css("color", thiscol);
 dom.find("#bustime"+num).text('\xa0'+hh+":"+((mm<10)?"0":"")+mm+'\xa0');
 dom.find("#busreal"+num).css("color", thiscol);
 dom.find("#busreal"+num).text(mintxt);
 if (togo>=0 && nextgo<0) { nextgo=diffmin; fnum=num+1; }
 num++;
 }
dom.find("#realtime").text("leave in " + (nextgo-mintogo) + " min");
}} 


where the auxiliary function buscolor() is simply

function buscolor(togo){
 if (togo<0) return 'rgb(200,180,180)';
 if (togo<=2) return 'rgb(255,200,0)';
 if (togo<=4) return 'rgb(255,0,0)';
 if (togo<=8) return 'rgb(255,64,64)';
 if (togo<=15) return 'rgb(255,100,100)';
 if (togo<60) return 'rgb(255,128,128)';
 if (togo<120) return 'rgb(255,128,255)';
 return 'rgb(255,0,255)' // error
}

That's it. Note the following:

  • The code handles both 12-hour and 24-hour times but the output is forced into 24-hour because it offers smaller footprint.
  • The item number 0 has a different, more verbose output format; the additional #realtime contains a hint when to leave home for the "next suitable" bus.
  • The container that should be called #busnum0 is called #bustoken instead. This is for historical reasons: the system looks for #bustoken as an indicator that the timetable has loaded.

It remains to be seen whether doing this every 10 seconds will prove feasible for the Playbook in terms of "mean time between refreshes / restarts". 

P.S. Turns out I had planned this feature all along, according to my own original post:


Our mileage may vary even further. For example:

  • we may imagine the code to throw out "missed" departures, moving the remaining ones up the queue, and even triggering an extra timetable refresh when there are too few (say <3) left;
  • we may intelligently trigger an extra refresh every time we are nearing the "leave home in 2 minutes situation in order to make sure that the upcoming bus is still on schedule;
  • we may keep track of how much the real-time departure information is changing between refreshes (provided they happen often enough) and detect when delays start to appear or frequently change (this is indicative of unstable traffic, warranting more frequent refreshes)...
...but we aren't building an after-market professional grade departure board here, and "perfect" is a very common enemy of the "good enough". 

Monday, August 6, 2018

Old Phone/Tablet as an Info Board: Table of Contents

Hi folks, now that this long overdue series of posts is complete, here's a table of contents for the ease of the reading.



Enjoy!

TL/DR: This is a series of (moderately boring) posts about how to turn your old and outdated tablet or smartphone into an information board you can use at home. Uses range from purely practical to purely aesthetic.

Old Phone/Tablet as an Info Board Final Part: Flow Control and Asynchronous Dynamic Data Refresh

This is the final post about how to make an information board out of your old tablet or smartphone.

Let's assume you have all your elements that deliver your information as you want it, beautifully designed and tested, again using my board as an example:




OK, the information is up to date when the page has loaded. Now, there comes a question: how to keep all this information reasonably up to date, ready for you at any moment in case you need it? On-demand refresh of any kind is not an option - an info board you'd have to stand in front of, waiting, is a lousy info board.

It would seem easy at first - "just do a META REFRESH", - but there are several pitfalls to think through:

  1. It is obvious that you need to refresh all the elements with some frequency. But how to choose this frequency? Too seldom, your info is out of date half the time. Too frequently, and your board will spend more time refreshing than actually showing the info, and you risk being kicked out by your data sources for DDOS'ing them. 
  2. Further elaborating on the idea, it is clear that some elements need a more frequent refresh than some others: you are quite OK with a weather forecast obtained half an hour ago, but train departures from half an hour ago are totally useless to you. 
  3. Furthermore, the refresh frequency should be time dependent. You don't need frequent traffic updates in the middle of the night, but you do need them during commute hours.
  4. What to do if an element fails to refresh, or gets stuck? Should we perhaps retry sooner than its normal refresh cycle? Then again, what would be the reasonable timeout? Set it too short and you'll mistake normal wait times for "getting stuck".
What it all means that you will need to refresh asynchronously (i.e. some elements but not others), and dynamically (using time intervals that are element and time dependent), and you'll have to have a mechanism to check if the refresh was actually successful or needs to be retried. Also note that some elements (notably those that use jQuery on a hidden iframe) will need a two-step refresh: some method to reload the iframe, and another to process its contents once it has loaded and rendered in DOM (but no sooner).



So, let us begin by introducing a "refresh handler" like so:

var dummydate = new Date();
var rExample = {freq: 30, // refresh every 30 minutes by default
    prime: [{freq: 5, start:{h:7, m:30}, end:{h:8, m:30}}, // between 7:30 and 8:30 refresh every 5 minutes
            {freq: 15, start:{h:15, m:30}, end:{h:20, m:30}}], // between 15:30 and 20:30 refresh every 15 minutes
    last: dummydate, next: dummydate, fresh: true,  overdue: 0, //internal variables
    lag: 5, retry: 45, //internal constants
    handleRefresh: function(){...}, // refresh function 
    handleReady: function(){...}, // "is ready" function
    handlePost: function(){...} // post-refresh function for 2-stage refresh
    };  


We see that this handler is a self-contained object that handles all refresh code for "something on the board". Then, given an array of these handlers, we define an event poller to be called periodically:

const scale = 1; //seconds per minute: 1 for debugging; 60 for actual use
var REFRESH = [rWeather, rTrain1, rTrain2, rBus, rHourly, rMap, rTravel];

function pollEvent()
{
var now= new Date();
// add force refresh @ 2AM, ONCE

for (var i=0, len=REFRESH.length; i<len; i++)
{
 var handle = REFRESH[i];
 if(now > handle.next) {
 try{
  if (handle.handleReady()) {
    handle.overdue=0;
 var behind = parseInt((now.getTime() - handle.last.getTime())/1000/scale);
 $("#ref"+(i+1)).text(behind);$("#ref"+(i+1)).css("color","green");
  }
  else { console.log(i+" not ready")
        if (handle.overdue > 5) {console.log("***FORCE REFRESH***"); /*location.reload();*/};
  handle.overdue++;
  $("#ref"+(i+1)).text("x");$("#ref"+(i+1)).css("color","darkred");
  };
  
   console.log("refresh "+i); 
   handle.handleRefresh(); handle.fresh = true;
   postprocess(handle);
   handle.last = now;
   handle.next = new Date(now.getTime() + 1000*((handle.overdue==0)?handle.retry:scale*timespan(handle,now)));
   } 
catch(ignore) {handle.overdue++; $("#ref"+(i+1)).text("X");$("#ref"+(i+1)).css("color","red"); handle.next = new Date(now.getTime() + 1000*handle.retry);} 
 }
}
setTimeout(pollEvent,100*scale); // call again, every 6 seconds in production 
};


Here timespan() defines the refresh frequency for a given handler at a given time:


 function timespan(handle,stamp)
{
 var ts = handle.freq;
 for (var j=0; j<handle.prime.length; j++)
 {
  var thisprime = handle.prime[j];
  if (stamp.getHours()>= thisprime.start.h && stamp.getHours()<= thisprime.end.h 
  && ( stamp.getHours()>thisprime.start.h || 
     (stamp.getHours() == thisprime.start.h && stamp.getMinutes()>= thisprime.start.m))
  && ( stamp.getHours()<thisprime.end.h || 
     (stamp.getHours() == thisprime.end.h && stamp.getMinutes()<= thisprime.end.m))
   ) {ts = thisprime.freq;}
 }
 return ts;
}


Now define another function to ensure two-step refresh happens as fast as possible:


function postprocess(handle){ 
 try{
  if(handle.handleReady) {console.log("++"); handle.handlePost();}
  else {console.log("--");setTimeout(function(){postprocess(handle);}, 1000*((handle.fresh)?1:handle.lag)); handle.fresh=false; };
}
 catch(ignore) {}
}


It only remains to define some auxiliary routines


function frameload(frameid){$(frameid).attr("src",$(frameid).attr("src"));}
function nop(){return true;}
function clock(){
var now = new Date(); var h=now.getHours(); var m=now.getMinutes(); var s = now.getSeconds();
$("#nowclock").text(((h>=10)?h:("0"+h)) + ":" + ((m>=10)?m:("0"+m)) + ":" + ((s>=10)?s:("0"+s)));} 


and add an initial invocation upon document's DOM ready:


$( document ).ready(function() 
{
var now = new Date(); var h=now.getHours(); var m=now.getMinutes();
$("#loadclock").text(((h>=10)?h:("0"+h)) + ":" + ((m>=10)?m:("0"+m)));
setInterval(clock, 1000);
setTimeout(pollEvent,3000);
});



That's all. For reference here are the refresh handlers for all the elements:


 var rWeather = {freq: 30, 
 prime: [{freq: 15, start:{h:7, m:30}, end:{h:8, m:30}}, 
   {freq: 15, start:{h:15, m:30}, end:{h:20, m:30}}],
 last: dummydate, next: dummydate, fresh: true,  overdue: 0, lag: 5, retry: 45,
 handleRefresh: function(){f(document, 'script', 'plmxbtn');},
 handleReady: function(){return $("#weather").find(".city").length>0;},
 handlePost:nop };
 
 var rTrain1 = {freq: 120, 
 prime: [{freq: 10, start:{h:5, m:30}, end:{h:9, m:0}}, 
   {freq: 2, start:{h:7, m:45}, end:{h:8, m:20}}],
 last: dummydate, next: dummydate, fresh: true,  overdue: 0, lag: 1, retry: 10,
 handleRefresh: function(){frameload("#go1");},
 handleReady: function(){return $("iframe#go1").contents().find(".currentDateMain").length>0;},
 handlePost:trainhide };
 
 var rTrain2 = {freq: 20, 
 prime: [{freq: 5, start:{h:7, m:30}, end:{h:8, m:30}}],
 last: dummydate, next: dummydate, fresh: true,  overdue: 0, lag: 1, retry: 10,
 handleRefresh: function(){frameload("#go2");},
 handleReady: function(){return $("iframe#go2").contents().find(".currentDateMain").length>0;},
 handlePost:trainhide };
 
 var rBus = {freq: 20, 
 prime: [{freq: 5, start:{h:6, m:15}, end:{h:9, m:0}}, 
   {freq: 1, start:{h:7, m:45}, end:{h:8, m:15}}],
 last: dummydate, next: dummydate, fresh: true,  overdue: 0, lag: 1, retry: 10,
 handleRefresh: function(){frameload("#miway");},
 handleReady: function(){return $("iframe#miway").contents().find("#bustoken").length>0;}, 
 handlePost:nop };
 
 var rHourly = {freq: 60, 
 prime: [{freq: 15, start:{h:7, m:30}, end:{h:8, m:30}}, 
   {freq: 15, start:{h:15, m:30}, end:{h:20, m:30}}],
 last: dummydate, next: dummydate, fresh: true,  overdue: 0, lag: 5, retry: 45,
 handleRefresh: function(){frameload("#forecast");},
 handleReady: function(){return $("iframe#forecast").contents().find("table.wxo-media")
   .find("[headers='header1']").length>0;},
 handlePost:forecast }; // *** add clear if exists
 
 var rMap = {freq: 30, 
 prime: [{freq: 5, start:{h:7, m:30}, end:{h:8, m:30}}, 
   {freq: 5, start:{h:15, m:30}, end:{h:18, m:00}}],
 last: dummydate, next: dummydate, fresh: true,  overdue: 0, lag: 10, retry: 45,
 handleRefresh: refreshMap,
 handleReady: function(){return true;}, 
 handlePost:nop }; // *** add clear if exists?
 
 var rTravel = {freq: 20, 
 prime: [{freq: 5, start:{h:7, m:30}, end:{h:8, m:30}}, 
   {freq: 5, start:{h:15, m:30}, end:{h:18, m:30}}, 
   {freq: 1, start:{h:7, m:50}, end:{h:8, m:10}}, 
   {freq: 1, start:{h:17, m:00}, end:{h:17, m:30}}],
 last: dummydate, next: dummydate, fresh: true,  overdue: 0, lag: 5, retry: 45,
 handleRefresh: navigate, 
 handleReady: function(){return ( parseInt($("#result1").text()) > 0 
   && parseInt($("#result2").text()) > 0);}, 
 handlePost:nop }; 




The code also includes forced refresh once daily at approximately 2:00 AM. This is done to prevent an occasional memory leak to screw our browser (we'll be running this 24/7 for months on end, remember?). I am leaving this part as an exercise for the reader - the flow chart is basically, "if the current time is between 2:00 and 3:00, and if the startup day is not the same as the current day, do location.reload()".

Still, I can see that the Playbook browser (or to be exact, an app called Backlight Override, which is just a browser wrapper that additionally prevents the backlight from ever going off) does crash - once every 3-4 weeks. Well, for me, this is a fairly acceptable "mean time between failures", even though anything more frequent than once a week would already border on annoying.


Wednesday, August 1, 2018

Old Phone/Tablet as an Info Board Part 4: IFRAME with PHP capture-and-rearrange

As we can see from the previous post, the capture-and-restyle method is ill-suited for larger target pages from which you only need a limited portion of data, and/or when you want to rearrange that data significantly. The reason is that it will take an enormous amount of analysis and restyling to get things look the way you want - on par with the effort needed to design a web site yourself.

Here I describe another, complementary approach, which I dub capture and rearrange, that you can use in exactly the opposite scenario:

  • your target page is not very lightweight,
  • you only need a small portion of the target's contents,
  • you need to rearrange the layout significantly. 


As an example, we will use Environment Canada's hourly forecast page to display a limited portion of the page (the hourly forecast) in a totally different format - horizontal rather than vertical layout, a much more condensed presentation, and adding visual aids and highlighting according to the weather conditions. 

Or in an example of a picture that's worth a thousand words, we would like to make this

from this (never mind the difference in the actual content; you get the idea)



The workflow is as follows:
  1. Capture the page via PHP in the previously described way, like so:
    <?php
    $opts = array('http'=>array('header' => "User-Agent:Mozilla/5.0 (Macintosh; Intel Mac OS X 10_6_8) AppleWebKit/534.59.10 (KHTML, like Gecko) Version/5.1.9 Safari/534.59.10\r\n"));
    $context = stream_context_create($opts);
    $code = file_get_contents("http://weather.gc.ca/forecast/hourly/on-24_metric_e.html",false,$context); 
    $code = str_replace("/weathericons/small/","http://weather.gc.ca/weathericons/small/",$code);
    $code = str_replace("Medium","Med",$code);
     
    echo $code;
    ?>
    


    Note that we have redirected the images directly to the server to spare the effort of saving locally.
    However, in my particular case, I found out that the Playbook browser (unlike newer browsers) had trouble accessing the server version of the images. Well, if you have read the previous post, you know the workaround: just create a local copy of weathericons/small/ and do this in any bash compatible terminal (Mac/Linux/Cygwin) - do not forget to set the execute attribute on the DiskStation:
    for $i in $(seq 0 9); do wget https://weather.gc.ca/weathericons/small/0$i.png; done
    for $i in $(seq 10 99); do wget https://weather.gc.ca/weathericons/small/$i.png; done
    


  • Then define a placeholder interface for our forecast, displaying our PHP captured page in a hidden iframe:
    <div id="hourly" class="basic info" style="position: absolute; left: 10px; bottom: 10px; width: 990px; height: 160px; background: white;">
    <iframe id="forecast" style="visibility: hidden; height: 0px !important;" src="hourly.php"> </iframe>
    <table > <tbody>
    <tr id="rowTime" class="element">
    </tr>
    <tr id="rowTemp" class="element" style="font-weight: bold; font-size: 16px;">
    </tr>
    <tr id="rowIcon" class="element">
    </tr>
    <tr id="rowText" class="element" style="font-size: 7px;">
    </tr>
    <tr id="rowRain" class="element">
    </tr>
    <tr id="rowWind" class="element">
    </tr>
    <tr id="rowChill" class="element">
    </tr>
    </tbody></table>
    </div>
    
  • Inspect the HTML of our source page to uniquely identify elements you want to use in your widget. This is the trickiest but the most creative part - I will detail what I did in my example, but it will be entirely dependent on the source website. As on many other occasions, the Inspect Element functionality in your browser is the tool of choice here. Fortunately, most websites nowadays are designed in such a way that all their elements are addressable by some combination of their IDs, class names and styles, which is what we need.
  • Once the frame is loaded, and once we know which elements to look for, we move those elements from the hidden iframe to our placeholder using jQuery's capabilities:
    Like so:
    function forecast()
    {
      $(".element").empty();
      var iFrameDOM = $("iframe#forecast").contents().find("table.wxo-media");
      $("#rowTime").append(iFrameDOM.find("[headers='header1']"));
      $("#rowTemp").append(iFrameDOM.find("[headers='header2']"));
      $("#rowIcon").append(iFrameDOM.find("img.media-object")); $("#rowIcon").find("img.media-object").wrap("<td> </td>");
      $("#rowText").append(iFrameDOM.find("div.media-body")); $("#rowText").find("div.media-body").wrap("<td> </td>");
      $("#rowRain").append(iFrameDOM.find("[headers='header4']"));
      $("#rowWind").append(iFrameDOM.find("[headers='header5']"));
      $("#rowChill").append(iFrameDOM.find("[headers='header7']"));
      
      $("#rowTemp").find("td").each(eachTemp);
      $("#rowRain").find("td").each(eachRain);
      $("#rowWind").find("td").each(eachWind);
      $("#rowChill").find("td").each(eachChill);
    }
    


  • The final four lines in the above snippet introduce content-dependent formatting of the newly added elements - I want to be able to see if there's rain/wind/heat/freeze from across the room. For this, we write a few auxiliary functions.

    Temperature gradient - this is just a linear interpolation between colors:
    function gradient(deg)
    {
    var colors = [ 
      [-15, 255,0,255],
      [-10, 255,128,255],
      [0, 128,128,255],
      [12, 128,255,255],
      [17, 150,255,128],
      [25, 255,255,128],
      [30, 255,128,0],
      [35, 255,0,0]
       ];
    // determine extrema
    if (deg <= colors[0][0]) {return "rgb(" + colors[0][1] + "," + colors[0][2] + "," + colors[0][3] + ")";}
    if (deg >= colors[7][0]) {return "rgb(" + colors[7][1] + "," + colors[7][2] + "," + colors[7][3] + ")";}
    //otherwise, interpolate
    for(i=1;i<=7;i++)
    {
       if(deg > colors[i-1][0] && deg <= colors[i][0])
       {
         var p = (deg-colors[i-1][0]) / (colors[i][0]-colors[i-1][0]);
         return "rgb(" 
      + Math.round(colors[i][1]*p + colors[i-1][1]*(1.0-p)) + "," 
      + Math.round(colors[i][2]*p + colors[i-1][2]*(1.0-p)) + "," 
      + Math.round(colors[i][3]*p + colors[i-1][3]*(1.0-p)) + ")";
       };
    }
    //error
    return "rgb(255,255,0)"; 
    }
    function eachTemp(){$(this).css("background",gradient(parseInt($(this).text())));}
    

    Rain highlighting:
    function eachRain(){
     var str = $(this).text();
     var result = "rgb(255,255,255)";var result2 = "rgb(0,0,0)";
     if (str=="Low") {result = "rgb(225,255,255)";}
     if (str=="Med") {result = "rgb(0,255,255)";}
     if (str=="High") {result = "rgb(0,0,255)";result2 = "rgb(255,255,255)";}
     $(this).css("background",result);
     $(this).css("color",result2);
    }
    

    Wind highlighting:
    function eachWind(){
     var str = $(this).text().trim().split(String.fromCharCode(160));
     var result = "rgb(255,255,255)"; var speed = 0; 
     console.log(str);
     try { speed = parseInt(str[1]);} catch(ignore){speed=0;}
     if (speed >=20 ) {result = "rgb(255,255,128)";}
     if (speed >=40 ) {result = "rgb(255,255,0)";}
     if (speed >=60 ) {result = "rgb(255,0,0)";}
     $(this).css("background",result);
    }
    

    Wind chill highlighting - note that it will depend on other element's content (hence the need to look beyond $(this) and therefore pass the index parameter.)
    function eachChill(index){
     var str = $(this).text()
     var result = "rgb(255,255,255)"; var chill = 0; var temp=0; var diff=0;
     try { chill = parseInt(str); temp = parseInt($($("#rowTemp").find("td")[index]).text()); diff = chill-temp; } 
     catch(ignore){diff=0;}
     if (diff<= -5) {result = "rgb(128,128,255)";}
     if (diff >= 5 ) {result = "rgb(255,128,128)";}
     $(this).css("background",result);
    }
    

    The final result looks more or less like in the picture above. All that remains is to call forecast() at some point after the hidden iframe has finished loading. I'll describe this in more detail in the final post that has details on flow control (which seems simple, but ended up being rather sophisticated for the sake of usability).


    Note that this design is not completely fool-proof. Table columns will change with depending on the content, some of the info may be clipped in some rare cases, and I have not yet extended the functionality to include both wind chill and humidex. All of these fixes may be considered exercises for the reader. After all, as a father of two with hardly any extended family support I had to become a firm believer in the Pareto principle.

    Thursday, August 6, 2015

    jQuery Tetris

    Following the previous exercise with jQuery, I wanted to do something more complicated, such as the classic Tetris game. Unlike some other examples where jQuery is primarily used for visualization and the game itself is implemented "traditionally" using a tile matrix, I was interested in using the powers of jQuery to program the game mechanics directly. (Discalimer: I was also interested in writing a working game as quickly as possible, and I implemented ideas on the go, so I apologize if some of the code will look unpolished.)

    So, let us, again, construct some skeleton interface:
    <!doctype html>
    <html>
    <head>
        <meta charset="utf-8">
        <title>jQuery tetris</title>
      <style>
      span {
        
        float: left;
        position:absolute;
        width:20px;
        height:20px;
      }
       span.element {     background-color: #3f3;   }
       span.backdrop {     background-color: #ff3;   }
       .box{position:absolute;background-color: #ffe;
            top:50px;left:0;
            width:500px;height:500px;}
      </style>
        
    </head>
    <body>
    
        <div id="container" class="box"></div>
    
        <button id="bLeft">Left (A)</button>
        <button id="bRight">Right (D)</button>
        ----
        <button id="bRotLeft">Rotate Left (Q)</button>
        <button id="bRotRight">Rotate Right (W)</button>
        ----
        <button id="bDown">Down (S)</button>    
        <button id="bGround">Ground</button>    
    
     
    <script src="jquery.js"></script>
    
    Here we reserve the class element to denote the tiles (squares) in the current shape that we can control, and backdrop will be all tiles that have fallen in place.

    Let's begin by writing some function that will generate a random Tetris shape. (I did all the testing using just one shape and then added the easiest kind of generator I could think of, so it is rather makeshift and does not ensure equal probability of all shapes. Still, it has the advantage of doing the job without switch-case constructs and copy-pastes):
    <script>
      
    function spawn(oX,oY,kind) {
     var cauldron='<span class="shape"> </span>';
     var potion='<span class="element"> </span>';
     var d1=kind%3; var d2=parseInt((kind%9)/3); var d3=parseInt(kind/9);
     return $(cauldron).appendTo($("#container")).css({left:oX+"px",top:oY+"px"})
      .append($(potion).css({left:(0)+"px",top:0+"px"}))
      .append($(potion).css({left:(0-20)+"px",top:0+"px"}))
      .append($(potion).css({left:0+(((d1==0)||((d2==d1)&&(d3!==0)))?20:(((d3==0))?0:-20))+"px",top:0+(((d1==0)||((d2==d1)&&(d3!==0)))?0:((d1==1)?20:-20))+"px"}))
      .append($(potion).css({left:-20+((d2==0)?-20:0)+"px",top:0+((d2==0)?0:((d2==1)?20:-20))+"px"}));
    }  
    
    As you can see, it places four tiles with class element into a container with class shape whose only purpose is to allow relative positioning of the tiles within the shape.

    Now let us program the functionality of the Left and Right buttons. This is conveniently done using the .offset() method:
    $("#bLeft").click(function(){var pos=current.offset().left;
                                 if(posLeftmost()>0){current.offset({left:pos-20})}})  
                                 
    $("#bRight").click(function(){var pos=current.offset().left;
                                 if(posRightmost()<480){current.offset({left:pos+20})}})  
    
    function posLeftmost(){var min=600;$(".element").each(function(){var here=$(this).offset().left;if (here<min) {min=here} }); return min}
    function posRightmost(){var max=0;$(".element").each(function(){var here=$(this).offset().left;if (here>max) {max=here} }); return max}
    

    where posLeftmost() and posRightmost() are two auxiliary functions to determine the position of the left-/rightmost tile in the shape, so that we cannot move the shape out of bounds. These functions do a simple max/min search through the positions of all tiles in the current shape.
    Moving down is implemented similarly, but here we need to check whether the already-fallen pieces block the movement of the current shape:
    $("#bDown").click(function(){var pos=current.offset().top;if (shapeCanMoveDown()) {current.offset({top:pos+20})}})  
    
    function auxIsFree(pos){var isFree=true;
                            $(".backdrop").each(function(){if (($(this).offset().top==pos.top + 20)&&($(this).offset().left==pos.left)){isFree=false}});return isFree}
    function shapeCanMoveDown(){var canMove=(posLowest()<530);
                                $(".element").each(function(){if (!auxIsFree($(this).offset())) {canMove=false}})
                                return canMove}
    function posLowest(){var max=0;$(".element").each(function(){var here=$(this).offset().top;if (here>max) {max=here} }); return max}
    

    As we see, we just search through the backdrop using .each() to see whether any backdrop tile occupies the space beneath each tile of the shape.
    Strictly speaking, left/right motion also needs this type of checking, which I did not bother to implement because it is totally analogous. We'll see below how this omission opens a way for "pass-through-wall" type cheats.

    Now, if the shape cannot move down any more, the rules dictate that it should freeze in place. To do this, we implement another function that simply moves all the element's tiles to the backdrop (and we tie it to the Ground button for testing purposes):
    function cement(){$(".element").removeClass("element").addClass("backdrop")}                               
    $("#bGround").click(function(){cement();current=spawn(240,40,Math.floor(Math.random()*18))})                       
    


    Rotating shapes is a bit more tricky. We make use of the relative placement of element's tiles in their container, manipulating their CSS position attributes:
    $("#bRotRight").click(function(){current.children().each(function(index){
            posX=parseInt($(this).css("left"));
            posY=parseInt($(this).css("top"));
                                    $(this).css({left:-posY,top:posX}) }) })
    
    $("#bRotLeft").click(function(){current.children().each(function(index){
            posX=parseInt($(this).css("left"));
            posY=parseInt($(this).css("top"));
                                    $(this).css({left:posY,top:-posX}) }) })
    
    Again we did not bother to do any bounds checking. One way of doing this would be to call auxIsFree() on all the shape tiles after rotation and unconditionally rotate in the opposite direction should any of the calls return false.


    What remains to be done logic-wise is the removal of filled lines from the backdrop. Perhaps too straightforward, my idea was to loop through the backdrop line by line (iterating y-coordinate) bottom to top and:
    - if there are "too many" tiles on the current line, remove such tiles from DOM, and
    - for all tiles above the current line, move them down in a similar fashion as we did with the element.
    The easiest way is via the .filter() method, like this:
    function posTallest(){var min=1000;$(".backdrop").each(function(){var here=$(this).offset().top;if (here<min) {min=here} }); return min}
    
    function rowCount(pos){return $(".backdrop").filter(function(){
                                   return $(this).offset().top==pos}).length}
    
    function backdropPROCESS(){var tallest=posTallest();
           for (pos=550 ; pos>=tallest;pos-=20)
                               { if (rowCount(pos)>=25) {
                                 $(".backdrop").filter(function(){return $(this).offset().top==pos}).remove();
                                 $(".backdrop").filter(function(){return $(this).offset().top<pos})
                                  .each(function(){$(this).offset({top:($(this).offset().top)+20})});
                                  pos+=20; //a mortal sin here but this is the easiest way to make an iteration repeat itself
                                 }
                                }}
    

    This is the only place we ever need a for-loop in the entire game. I am pretty sure I could do without hard-coding y-coordinates but could not wait to get a functional game.

    Finally, let us add the main controller for the game, launching it when the document's DOM is ready:
    function TetrisLOOP(){
     var speed=500;
     var pos=current.offset().top;
     if (shapeCanMoveDown()) {current.offset({top:pos+20});setTimeout(TetrisLOOP,speed)}
     else {
      cement(); 
      backdropPROCESS();
      if (posTallest() > 150) {current=spawn(240,40,Math.floor(Math.random()*27));setTimeout(TetrisLOOP,speed)} 
      else {$("#container").css("background","#ffcccc").append($("<h1> Game over </h1>"));
        $(".backdrop").css("background","red");
        $("#bGround").removeAttr('disabled');}
      }
    
    $( document ).ready(function() {
     $("#bGround").attr('disabled','disabled');
     current=spawn(240,40,Math.floor(Math.random()*10));
     var mainLOOP=setTimeout(TetrisLOOP,1000);
    });
    

    and a (very primitive) code block to enable WSAD-style keyboard control:
    $(document).keypress(function(event){switch(event.which)
      {case 97:$("#bLeft").click();break;
       case 100:$("#bRight").click();break;
       case 115:$("#bDown").click();break;
       case 113:$("#bRotLeft").click();break;
       case 119:$("#bRotRight").click();break;}
      })
    

    And we're all set - enjoy! Here is the link to the complete code for your experimentation.
    Since I was yearning to get a functional jQuery Tetris as quickly as I could, I blatantly ignored all the "design" elements (grid, bordered tiles, varying colors etc.), as well as purely gameplay-ish issues such as displaying the next shape, scoring, and varying speed/levels. All of this can be implemented rather trivially. There are also a number of bugs stemming from the absent movability checks for left/right/rotate operations. I leave it to the interested reader to see what "cheat issues" this can cause and how to correct them. :D



    Wednesday, August 5, 2015

    My jQuery "Hello World"

    Here is a small example of a simple "Hello World" program that I wrote when getting familiar with jQuery (and, largely, JavaScript itself for that matter). 

    To stand out from the crowd of programs that just display "Hello World" on the screen, the program helps the user say the real hello to the real world. For a country of choice, it checks the Timatic database to determine how easy it would be to actually travel there, based on the user's nationality and country of residence.

    So let's construct some skeleton interface:

    <html>
    <head>
        <meta charset="utf-8">
        <title>jQuery Hello (real) World</title>
      <style>
      
      iframe { position: absolute;
       left: 0;
       top: 100px;}
      .large{background-color: #ffffee;width:650px;height:400px;float:left;}
      form{ font-family: "Arial"; float:left;}
      input{width:40px;}
      </style>
    </head>
    <body>
    <form id="dynamicForm">TIRV:</form>
        
    <iframe id="timatic" class="large" src="https://www.timaticweb.com/cgi-bin/tim_client.cgi?ExpertMode=TIHELP&user=OMITTED&subuser=OMITTEDB2C"></iframe>
    

    where the <iframe> element will contain the result of the Timatic query. Let's then define two functions that perform the query:

    <script src="jquery.js"></script>
    <script>
       
    function sendquery() {
    $("#timatic").attr("src","https://www.timaticweb.com/cgi-bin/tim_client.cgi?ExpertMode="+formstring()+"&user=OMITTED&subuser=OMITTEDB2C")
    }
    
    function formstring() {
     return "TIRV/"+$("input").map(function(){if (this.value!=="") {return this.id+this.value}}).get().join("/")+"/";
    }
    

    We note that the function formstring() refers to the <input> elements but there are none in the document. We will populate the interface from the script using jQuery methods:
    $( document ).ready(function() {
    //dynamically generate form 
    var tokens=['NA','AR','DE','TR'];
    tokens.forEach(function(token) {
     $("#dynamicForm").append($('<label for="'+token+'"> '+token+': </label>'));
     $("#dynamicForm").append($('<input id="'+token+'" type="text" onchange="sendquery()">'));
    } )
    });
    </script>
    </body>
    </html>
    

    That's it. These few lines create four input fields along with their respective labels and event handlers, so the sendquery() is called whenever the fields are changed. Note that the key strings in the Timatic query (NA/AR/DE/TR) are used for the element's ID, which allows to generate the query string automatically in a one-liner formstring() (see line #27) rather than construct it manually from four input fields. It also allows for easy scalability should there be more fields in the query string. (Timatic gurus: feel free to challenge yourselves to add functionality for a health (TIRH/TIRA) query that includes embarkation country (EM) and recently visited countries (VI) fields.

    Here's an example:

    Checking if a programmer from Belarus with a US residence can say "Hello World" in Canada while transiting Georgia
    Note that the above examples won't work as quoted because I've stripped the credentials needed to query the Timatic Web service - sorry folks but I do not want to bust my Timatic access. However there are many (mostly airline and IATA) websites out there that offer Timatic service (albeit with a much more complicated interface), and access credentials could be easily fished by inspecting the source code of those websites.