From 1d8e66016c4cdf465d06198cfbbfe76613ed3bfc Mon Sep 17 00:00:00 2001 From: Matt Amos Date: Thu, 25 Jun 2009 23:31:53 +0000 Subject: [PATCH] Adding 'shortlink' functions which will allow URLs like http://osm.org/go/XXXX suitable for use in twitter, etc... --- app/controllers/site_controller.rb | 20 +++++++- app/views/site/index.html.erb | 5 +- config/locales/en.yml | 1 + config/routes.rb | 3 ++ lib/short_link.rb | 79 +++++++++++++++++++++++++++++ public/javascripts/site.js | 45 ++++++++++++++++ public/stylesheets/site.css | 1 + test/integration/short_link_test.rb | 35 +++++++++++++ test/unit/short_link_test.rb | 26 ++++++++++ 9 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 lib/short_link.rb create mode 100644 test/integration/short_link_test.rb create mode 100644 test/unit/short_link_test.rb diff --git a/app/controllers/site_controller.rb b/app/controllers/site_controller.rb index 2a826770d..1478c5773 100644 --- a/app/controllers/site_controller.rb +++ b/app/controllers/site_controller.rb @@ -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 diff --git a/app/views/site/index.html.erb b/app/views/site/index.html.erb index ecb732c9b..d93b80b8a 100644 --- a/app/views/site/index.html.erb +++ b/app/views/site/index.html.erb @@ -17,7 +17,10 @@
- +
diff --git a/config/locales/en.yml b/config/locales/en.yml index ad24fa16c..3363c71cc 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -541,6 +541,7 @@ en: js_2: "OpenStreetMap uses javascript for its slippy map." js_3: 'You may want to try the Tiles@Home static tile browser 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" diff --git a/config/routes.rb b/config/routes.rb index b410c2b90..6dd3860dc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 index 000000000..afcf1ef37 --- /dev/null +++ b/lib/short_link.rb @@ -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 diff --git a/public/javascripts/site.js b/public/javascripts/site.js index e0c18a27b..23ea3bc68 100644 --- a/public/javascripts/site.js +++ b/public/javascripts/site.js @@ -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; +} diff --git a/public/stylesheets/site.css b/public/stylesheets/site.css index 8d7324fc3..86b38f8ac 100644 --- a/public/stylesheets/site.css +++ b/public/stylesheets/site.css @@ -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 index 000000000..91f939a52 --- /dev/null +++ b/test/integration/short_link_test.rb @@ -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 index 000000000..bbae95106 --- /dev/null +++ b/test/unit/short_link_test.rb @@ -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 -- 2.39.5