Adding 'shortlink' functions which will allow URLs like http://osm.org/go/XXXX suitab...
authorMatt Amos <zerebubuth@gmail.com>
Thu, 25 Jun 2009 23:31:53 +0000 (23:31 +0000)
committerMatt Amos <zerebubuth@gmail.com>
Thu, 25 Jun 2009 23:31:53 +0000 (23:31 +0000)
app/controllers/site_controller.rb
app/views/site/index.html.erb
config/locales/en.yml
config/routes.rb
lib/short_link.rb [new file with mode: 0644]
public/javascripts/site.js
public/stylesheets/site.css
test/integration/short_link_test.rb [new file with mode: 0644]
test/unit/short_link_test.rb [new file with mode: 0644]

index 2a826770d4144b2eafa680ebdd0f189f56de0b79..1478c5773cda99cf2b59c9b15f6fbfc4eaba6cb3 100644 (file)
@@ -1,5 +1,5 @@
 class SiteController < ApplicationController
-  layout 'site',:except => [:key]
+  layout 'site', :except => [:key, :permalink]
 
   before_filter :authorize_web
   before_filter :set_locale
@@ -9,6 +9,24 @@ class SiteController < ApplicationController
     render :action => 'index'
   end
 
+  def permalink
+    lon, lat, zoom = ShortLink::decode(params[:code])
+    new_params = params.clone
+    new_params.delete :code
+    if new_params.has_key? :m
+      new_params.delete :m
+      new_params[:mlat] = lat
+      new_params[:mlon] = lon
+    else
+      new_params[:lat] = lat
+      new_params[:lon] = lon
+    end
+    new_params[:zoom] = zoom
+    new_params[:controller] = 'site'
+    new_params[:action] = 'index'
+    redirect_to new_params
+  end
+
   def key
     expires_in 7.days, :public => true
   end
index ecb732c9b198d5e74daeb4014e11c0773bdff2c5..d93b80b8a9acfbaa602613b73e79504c175f76e3 100644 (file)
 </noscript>
 
 <div id="map">
-<div id="permalink"><a href="/" id="permalinkanchor"><%= t 'site.index.permalink' %></a></div>
+  <div id="permalink">
+    <a href="/" id="permalinkanchor"><%= t 'site.index.permalink' %></a><br/>
+    <a href="/" id="shortlinkanchor"><%= t 'site.index.shortlink' %></a>
+  </div>
 </div> 
 
 <div id="attribution">
index ad24fa16c88af381a7327347f16487a566fc10f3..3363c71cc05ae35666a49352d2d9fa041e781586 100644 (file)
@@ -541,6 +541,7 @@ en:
       js_2: "OpenStreetMap uses javascript for its slippy map."
       js_3: 'You may want to try the <a href="http://tah.openstreetmap.org/Browse/">Tiles@Home static tile browser</a> if you are unable to enable javascript.'
       permalink: Permalink
+      shortlink: Shortlink
       license:
         notice: "Licensed under the {{license_name}} license by the {{project_name}} and its contributors."
         license_name: "Creative Commons Attribution-Share Alike 2.0"
index b410c2b90fd34701a8dc3ec3e13d51a0674acb82..6dd3860dcf0f74221afeea9446bb9253280ece5a 100644 (file)
@@ -114,6 +114,9 @@ ActionController::Routing::Routes.draw do |map|
   map.connect '/create-account.html', :controller => 'user', :action => 'new'
   map.connect '/forgot-password.html', :controller => 'user', :action => 'lost_password'
 
+  # permalink
+  map.connect '/go/:code', :controller => 'site', :action => 'permalink', :code => /[a-zA-Z0-9_@]+=*/
+
   # traces  
   map.connect '/traces', :controller => 'trace', :action => 'list'
   map.connect '/traces/page/:page', :controller => 'trace', :action => 'list'
diff --git a/lib/short_link.rb b/lib/short_link.rb
new file mode 100644 (file)
index 0000000..afcf1ef
--- /dev/null
@@ -0,0 +1,79 @@
+##
+# Encodes and decodes locations from Morton-coded "quad tile" strings. Each
+# variable-length string encodes to a precision of one pixel per tile (roughly,
+# since this computation is done in lat/lon coordinates, not mercator).
+# Each character encodes 3 bits of x and 3 of y, so there are extra characters
+# tacked on the end to make the zoom levels "work".
+module ShortLink
+
+  # array of 64 chars to encode 6 bits. this is almost like base64 encoding, but
+  # the symbolic chars are different, as base64's + and / aren't very 
+  # URL-friendly.
+  ARRAY = ('A'..'Z').to_a + ('a'..'z').to_a + ('0'..'9').to_a + ['_','@']
+
+  ##
+  # Given a string encoding a location, returns the [lon, lat, z] tuple of that 
+  # location.
+  def self.decode(str)
+    x = 0
+    y = 0
+    z = 0
+    z_offset = 0
+
+    str.each_char do |c|
+      t = ARRAY.index c
+      if t.nil?
+        z_offset -= 1
+      else
+        3.times do
+          x <<= 1; x = x | 1 unless (t & 32).zero?; t <<= 1
+          y <<= 1; y = y | 1 unless (t & 32).zero?; t <<= 1
+        end
+        z += 3
+      end
+    end
+    # pack the coordinates out to their original 32 bits.
+    x <<= (32 - z)
+    y <<= (32 - z)
+
+    # project the parameters back to their coordinate ranges.
+    [(x * 360.0 / 2**32) - 180.0, 
+     (y * 180.0 / 2**32) - 90.0, 
+     z - 8 - (z_offset % 3)]
+  end
+
+  ##
+  # given a location and zoom, return a short string representing it.
+  def self.encode(lon, lat, z)
+    code = interleave_bits(((lon + 180.0) * 2**32 / 360.0).to_i, 
+                           ((lat +  90.0) * 2**32 / 180.0).to_i)
+    str = ""
+    # add eight to the zoom level, which approximates an accuracy of
+    # one pixel in a tile.
+    ((z + 8)/3.0).ceil.times do |i|
+      digit = (code >> (58 - 6 * i)) & 0x3f
+      str << ARRAY[digit]
+    end
+    # append characters onto the end of the string to represent
+    # partial zoom levels (characters themselves have a granularity
+    # of 3 zoom levels).
+    ((z + 8) % 3).times { str << "=" }
+    
+    return str
+  end
+
+  private
+  
+  ##
+  # interleaves the bits of two 32-bit numbers. the result is known
+  # as a Morton code.
+  def self.interleave_bits(x, y)
+    c = 0
+    31.downto(0) do |i|
+      c = (c << 1) | ((x >> i) & 1)
+      c = (c << 1) | ((y >> i) & 1)
+    end
+    c
+  end
+
+end
index e0c18a27b5005fee572114de23388d360ef31b0e..23ea3bc6839acf7bffe4226e7dce207454942001 100644 (file)
@@ -84,6 +84,20 @@ function updatelinks(lon,lat,zoom,layers,minlon,minlat,maxlon,maxlat) {
       node.style.fontStyle = 'italic';
     }
   }
+
+  node = document.getElementById("shortlinkanchor");
+  if (node) {
+    var args = getArgs(node.href);
+    var code = makeShortCode(lat, lon, zoom);
+    // little hack. may the gods of hardcoding please forgive me, or 
+    // show me the Right way to do it.
+    if (layers && (layers != "B000FTF")) {
+      args["layers"] = layers;
+      node.href = setArgs("/go/" + code, args);
+    } else {
+      node.href = "/go/" + code;
+    }
+  }
 }
 
 function getArgs(url) {
@@ -158,3 +172,34 @@ function i18n(string, keys) {
    
   return string;
 } 
+
+function makeShortCode(lat, lon, zoom) {
+    char_array = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_@";
+    var x = Math.round((lon + 180.0) * ((1 << 30) / 90.0));
+    var y = Math.round((lat +  90.0) * ((1 << 30) / 45.0));
+    // hack around the fact that JS apparently only allows 53-bit integers?!?
+    // note that, although this reduces the accuracy of the process, it's fine for
+    // z18 so we don't need to care for now.
+    var c1 = 0, c2 = 0;
+    for (var i = 31; i > 16; --i) {
+       c1 = (c1 << 1) | ((x >> i) & 1);
+       c1 = (c1 << 1) | ((y >> i) & 1);
+    }
+    for (var i = 16; i > 1; --i) {
+       c2 = (c2 << 1) | ((x >> i) & 1);
+       c2 = (c2 << 1) | ((y >> i) & 1);
+    }
+    var str = "";
+    for (var i = 0; i < Math.ceil((zoom + 8) / 3.0) && i < 5; ++i) {
+       digit = (c1 >> (24 - 6 * i)) & 0x3f;
+       str += char_array.charAt(digit);
+    }
+    for (var i = 5; i < Math.ceil((zoom + 8) / 3.0); ++i) {
+       digit = (c2 >> (24 - 6 * (i - 5))) & 0x3f;
+       str += char_array.charAt(digit);
+    }
+    for (var i = 0; i < ((zoom + 8) % 3); ++i) {
+       str += "=";
+    }
+    return str;
+}
index 8d7324fc365738c5301ec52d8d444634e467885a..86b38f8ace41b1f65f8e302e2b42d634c8b8b394 100644 (file)
@@ -617,6 +617,7 @@ input[type="submit"] {
   bottom:15px;
   right:15px;
   font-size:smaller;
+  text-align: right;
 }
 
 #attribution {
diff --git a/test/integration/short_link_test.rb b/test/integration/short_link_test.rb
new file mode 100644 (file)
index 0000000..91f939a
--- /dev/null
@@ -0,0 +1,35 @@
+require File.dirname(__FILE__) + '/../test_helper'
+
+class ShortLinkTest < ActionController::IntegrationTest
+  ##
+  # test the short link with various parameters and ensure they're
+  # kept in the redirect.
+  def test_short_link_params
+    assert_short_link_redirect('1N8H@P_5W')
+    assert_short_link_redirect('euu4oTas==')
+  end
+
+  ##
+  # utility method to test short links
+  def assert_short_link_redirect(short_link)
+    lon, lat, zoom = ShortLink::decode(short_link)
+
+    # test without marker
+    get '/go/' + short_link
+    assert_redirected_to :controller => 'site', :action => 'index', :lat => lat, :lon => lon, :zoom => zoom
+
+    # test with marker
+    get '/go/' + short_link + "?m"
+    assert_redirected_to :controller => 'site', :action => 'index', :mlat => lat, :mlon => lon, :zoom => zoom
+
+    # test with layers and a marker
+    get '/go/' + short_link + "?m&layers=B000FTF"
+    assert_redirected_to :controller => 'site', :action => 'index', :mlat => lat, :mlon => lon, :zoom => zoom, :layers => "B000FTF"
+    get '/go/' + short_link + "?layers=B000FTF&m"
+    assert_redirected_to :controller => 'site', :action => 'index', :mlat => lat, :mlon => lon, :zoom => zoom, :layers => "B000FTF"
+
+    # test with some random query parameters we haven't even implemented yet
+    get '/go/' + short_link + "?foobar=yes"
+    assert_redirected_to :controller => 'site', :action => 'index', :lat => lat, :lon => lon, :zoom => zoom, :foobar => "yes"
+  end
+end
diff --git a/test/unit/short_link_test.rb b/test/unit/short_link_test.rb
new file mode 100644 (file)
index 0000000..bbae951
--- /dev/null
@@ -0,0 +1,26 @@
+require File.dirname(__FILE__) + '/../test_helper'
+
+class ShortLinkTest < ActiveSupport::TestCase
+  ##
+  # tests that encoding and decoding are working to within
+  # the acceptable quantisation range.
+  def test_encode_decode
+    cases = Array.new
+    1000.times do 
+      cases << [ 180.0 * rand - 90.0, 360.0 * rand - 180.0, (18 * rand).to_i ]
+    end
+
+    cases.each do |lat, lon, zoom|
+      lon2, lat2, zoom2 = ShortLink.decode(ShortLink.encode(lon, lat, zoom))
+      # zooms should be identical
+      assert_equal zoom, zoom2, "Decoding a encoded short link gives different zoom for (#{lat}, #{lon}, #{zoom})."
+      # but the location has a quantisation error introduced at roughly 
+      # one pixel (i.e: zoom + 8). the sqrt(5) is because each position 
+      # has an extra bit of accuracy in the lat coordinate, due to the 
+      # smaller range.
+      distance = Math.sqrt((lat - lat2) ** 2 + (lon - lon2) ** 2)
+      max_distance = 360.0 / (1 << (zoom + 8)) * 0.5 * Math.sqrt(5)
+      assert max_distance > distance, "Maximum expected error exceeded: #{max_distance} <= #{distance} for (#{lat}, #{lon}, #{zoom})."
+    end
+  end
+end