├── README.md ├── scripts ├── changeset.php ├── db.inc.php ├── parse_osc.pl ├── rss.php └── tiles.php ├── wdi_guide.svg └── www ├── OpenLayers.js ├── img ├── close.gif └── cloud-popup-relative.png ├── index.html ├── loading.gif ├── style.css ├── wdi_guide.gif └── whodidit.js /README.md: -------------------------------------------------------------------------------- 1 | # WHODIDIT: OpenStreetMap Changeset Analyzer 2 | 3 | This tool downloads replication diffs from OSM Planet site, calculates statistics on changes 4 | and registers which 0.01-degree tiles were affected, and stores this in a MySQL database. 5 | A series of PHP scripts and a JS frontend are used to access that data. 6 | 7 | You can check a working installation at http://zverik.openstreetmap.ru/whodidit/ 8 | 9 | ## Installation 10 | 11 | ### Database 12 | 13 | Make a directory outside www root (for example, `/home/?/whodidit`) 14 | and place `parse_osc.pl` there. Then create mysql database with utf8 collation and grant a user 15 | right to create and update tables there. After that, create database tables: 16 | 17 | ./parse_osc.pl -h -d -u -p -c -v 18 | 19 | Add the script to crontab: 20 | 21 | 6 * * * * /home/?/whodidit/parse_osc.pl -h -d -u -p \ 22 | -l http://planet.openstreetmap.org/replication/hour/ \ 23 | -s /home/?/whodidit/state.txt -w /usr/local/bin/wget 24 | 25 | Now each hour your database will be updated with fresh data. Note that the same osmChange 26 | file **should not** be processed twice: the database has no means of skipping already 27 | processed files. 28 | 29 | If you do not want to wait several days to import backlog of changesets, you can download 30 | a weekly backup (and a relevant state.txt) from http://zverik.openstreetmap.ru/whodidit/backup/ 31 | 32 | ### Frontend 33 | 34 | Make a directory inside www root, for example, `/var/www/whodidit`. Put all files 35 | from `www` directory in it. Then create another directory, `/var/www/whodidit/scripts` 36 | and put there all four PHP scripts from `scripts`. 37 | 38 | Update the line `` in `index.html` 39 | with the absolute URL of the directory you've put PHP files in. Then edit 40 | `db.inc.php` script, updating `$frontend_url` variable with the absolute path to `index.html`. 41 | 42 | Then write your database parameters into `connect()` function in `db.inc.php`, and you're set. 43 | 44 | ## What do scripts do? 45 | 46 | * `parse_osc.pl`: This script downloads and parses replication diffs, storing changeset information 47 | in a MySQL database. It can create tables (with `-c` switch). Run it without parameters 48 | to see a list of all possible options. 49 | * `db.inc.php`: Global settings for PHP scripts, also two useful functions (which can be updated 50 | in later versions, so be careful not to lose your settings -- sorry). 51 | * `tiles.php`: Queries the database for tiles in an area. Returns JSON with either error message 52 | (large areas and areas that have more than 1000 tiles are rejected) or all tiles with changeset 53 | numbers and other information. 54 | * `changeset.php`: Returns a JSON with detailed information for requested changeset ids. When 55 | called with `latest=1` parameter, returns the latest changeset. 56 | * `rss.php`: As the title suggests, it generated an RSS feed with the latest changesets in a bbox. 57 | * `index.html`: The HTML page is a front-end to WDI infrastructure. It makes use of all PHP scripts 58 | and allows user to check WDI tiles and acquire RSS links. 59 | * `whodidit.js`: The JavaScript behind the front-end. 60 | 61 | ## Author 62 | 63 | Everything here (except OpenLayers, of course) is written by Ilya Zverev, licensed WTFPL. 64 | -------------------------------------------------------------------------------- /scripts/changeset.php: -------------------------------------------------------------------------------- 1 | query('select * from wdi_changesets where '.$where); 20 | print '['; 21 | $first = true; 22 | while( $row = $res->fetch_assoc() ) { 23 | if( $first ) $first = false; else print ",\n"; 24 | $row['suspicious'] = is_changeset_suspicious($row) ? 1 : 0; 25 | print json_encode($row); 26 | } 27 | print ']'; 28 | ?> 29 | -------------------------------------------------------------------------------- /scripts/db.inc.php: -------------------------------------------------------------------------------- 1 | set_charset('utf8'); 9 | return $db; 10 | } 11 | 12 | function parse_bbox( $bbox_str ) { 13 | global $tile_size; 14 | if( !preg_match('/^-?[\d.]+(,-?[\d.]+){3}$/', $bbox_str) ) return 0; 15 | $bbox = explode(',', $bbox_str); 16 | for( $i = 0; $i < 4; $i++ ) 17 | $bbox[$i] = floor($bbox[$i]/$tile_size); 18 | if( $bbox[2] < $bbox[0] ) { $t = $bbox[2]; $bbox[2] = $bbox[0]; $bbox[0] = $t; } 19 | if( $bbox[3] < $bbox[1] ) { $t = $bbox[3]; $bbox[3] = $bbox[1]; $bbox[1] = $t; } 20 | return $bbox; 21 | } 22 | 23 | function is_changeset_suspicious( $ch ) { 24 | // more than 30% of node or way deletions (allow 11 and 6) 25 | if( $ch['nodes_deleted'] > $ch['nodes_modified'] + $ch['nodes_created'] + 10 ) return true; 26 | if( $ch['ways_deleted'] > $ch['ways_modified'] + $ch['ways_created'] + 5 ) return true; 27 | // more relations deleted than created (allow 3) 28 | if( $ch['relations_deleted'] > $ch['relations_created'] + 2 ) return true; 29 | // mass-change/deletion 30 | if( $ch['nodes_modified'] + $ch['nodes_deleted'] + $ch['ways_modified'] + $ch['ways_deleted'] > 5000 ) return true; 31 | if( $ch['relations_modified'] + $ch['relations_deleted'] > 40 ) return true; 32 | // potlatch + relations modified or deleted 33 | if( strpos($ch['created_by'], 'Potlatch') !== FALSE && $ch['relations_modified'] + $ch['relations_deleted'] > 0 ) return true; 34 | // well, seems normal 35 | return false; 36 | } 37 | ?> 38 | -------------------------------------------------------------------------------- /scripts/parse_osc.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | # This tool either processes a single osc file or downloads replication osc base on state file. 4 | # The result is inserted into whodidit database. 5 | # Written by Ilya Zverev, licensed WTFPL. 6 | 7 | use strict; 8 | use Getopt::Long; 9 | use File::Basename; 10 | use LWP::Simple; 11 | use IO::Uncompress::Gunzip; 12 | use DBIx::Simple; 13 | use XML::LibXML::Reader qw( XML_READER_TYPE_ELEMENT XML_READER_TYPE_END_ELEMENT ); 14 | use POSIX; 15 | use Devel::Size qw(total_size); 16 | use Time::HiRes qw(gettimeofday tv_interval); 17 | use Cwd qw(abs_path); 18 | 19 | my $wget = `/usr/bin/which wget` || 'wget'; 20 | $wget =~ s/\s//s; 21 | my $state_file = dirname(abs_path(__FILE__)).'/state.txt'; 22 | my $stop_file = abs_path(__FILE__); 23 | $stop_file =~ s/(\.pl|$)/.stop/; 24 | my $help; 25 | my $verbose; 26 | my $filename; 27 | my $url; 28 | my $database; 29 | my $dbhost = 'localhost'; 30 | my $user; 31 | my $password; 32 | my $zipped; 33 | my $tile_size = 0.01; 34 | my $clear; 35 | my $bbox_str = '-180,-90,180,90'; 36 | my $dbprefix = 'wdi_'; 37 | 38 | GetOptions(#'h|help' => \$help, 39 | 'v|verbose' => \$verbose, 40 | 'i|input=s' => \$filename, 41 | 'z|gzip' => \$zipped, 42 | 'l|url=s' => \$url, 43 | 'd|database=s' => \$database, 44 | 'h|host=s' => \$dbhost, 45 | 'u|user=s' => \$user, 46 | 'p|password=s' => \$password, 47 | 't|tilesize=f' => \$tile_size, 48 | 'c|clear' => \$clear, 49 | 's|state=s' => \$state_file, 50 | 'w|wget=s' => \$wget, 51 | 'b|bbox=s' => \$bbox_str, 52 | ) || usage(); 53 | 54 | if( $help ) { 55 | usage(); 56 | } 57 | 58 | usage("Please specify database and user names") unless $database && $user; 59 | my $db = DBIx::Simple->connect("DBI:mysql:database=$database;host=$dbhost;mysql_enable_utf8=1", $user, $password, {RaiseError => 1}); 60 | create_table() if $clear; 61 | my $ua = LWP::UserAgent->new(); 62 | $ua->env_proxy; 63 | 64 | my @bbox = split(",", $bbox_str); 65 | die ("badly formed bounding box - use four comma-separated values for left longitude, ". 66 | "bottom latitude, right longitude, top latitude") unless $#bbox == 3; 67 | die("max longitude is less than min longitude") if ($bbox[2] < $bbox[0]); 68 | die("max latitude is less than min latitude") if ($bbox[3] < $bbox[1]); 69 | 70 | if( $filename ) { 71 | open FH, "<$filename" or die "Cannot open file $filename: $!"; 72 | my $h = $zipped ? new IO::Uncompress::Gunzip(*FH) : *FH; 73 | print STDERR $filename.': ' if $verbose; 74 | process_osc($h); 75 | close $h; 76 | } elsif( $url ) { 77 | $url =~ s#^#http://# unless $url =~ m#://#; 78 | $url =~ s#/$##; 79 | update_state($url); 80 | } else { 81 | usage("Please specify either filename or state.txt URL"); 82 | } 83 | 84 | sub update_state { 85 | my $state_url = shift; 86 | my $resp = $ua->get($state_url.'/state.txt'); 87 | die "Cannot download $state_url/state.txt: ".$resp->status_line unless $resp->is_success; 88 | print STDERR "Reading state from $state_url/state.txt\n" if $verbose; 89 | $resp->content =~ /sequenceNumber=(\d+)/; 90 | die "No sequence number in downloaded state.txt" unless $1; 91 | my $last = $1; 92 | 93 | if( !-f $state_file ) { 94 | # if state file does not exist, create it with the latest state 95 | open STATE, ">$state_file" or die "Cannot write to $state_file"; 96 | print STATE "sequenceNumber=$last\n"; 97 | close STATE; 98 | } 99 | 100 | my $cur = $last; 101 | open STATE, "<$state_file" or die "Cannot open $state_file"; 102 | while() { 103 | $cur = $1 if /sequenceNumber=(\d+)/; 104 | } 105 | close STATE; 106 | die "No sequence number in file $state_file" if $cur < 0; 107 | die "Last state $last is less than DB state $cur" if $cur > $last; 108 | if( $cur == $last ) { 109 | print STDERR "Current state is the last, no update needed.\n" if $verbose; 110 | exit 0; 111 | } 112 | 113 | print STDERR "Last state $cur, updating to state $last\n" if $verbose; 114 | for my $state ($cur+1..$last) { 115 | die "$stop_file found, exiting" if -f $stop_file; 116 | my $osc_url = $state_url.sprintf("/%03d/%03d/%03d.osc.gz", int($state/1000000), int($state/1000)%1000, $state%1000); 117 | print STDERR $osc_url.': ' if $verbose; 118 | open FH, "$wget -q -O- $osc_url|" or die "Failed to open: $!"; 119 | process_osc(new IO::Uncompress::Gunzip(*FH)); 120 | close FH; 121 | 122 | open STATE, ">$state_file" or die "Cannot write to $state_file"; 123 | print STATE "sequenceNumber=$state\n"; 124 | close STATE; 125 | } 126 | } 127 | 128 | sub process_osc { 129 | my $handle = shift; 130 | my $r = XML::LibXML::Reader->new(IO => $handle); 131 | my %comments; 132 | my %tiles; 133 | my $state = ''; 134 | my $tilesc = 0; 135 | my $clock = [gettimeofday]; 136 | while($r->read) { 137 | if( $r->nodeType == XML_READER_TYPE_ELEMENT ) { 138 | if( $r->name eq 'modify' ) { 139 | $state = 'modified'; 140 | } elsif( $r->name eq 'delete' ) { 141 | $state = 'deleted'; 142 | } elsif( $r->name eq 'create' ) { 143 | $state = 'created';; 144 | } elsif( ($r->name eq 'node' || $r->name eq 'way' || $r->name eq 'relation') && $state ) { 145 | my $changeset = $r->getAttribute('changeset'); 146 | my $change = $comments{$changeset}; 147 | if( !defined($change) ) { 148 | $change = get_changeset($changeset); 149 | $comments{$changeset} = $change; 150 | } 151 | $change->{$r->name.'s_'.$state}++; 152 | my $time = $r->getAttribute('timestamp'); 153 | $change->{time} = $time if $time gt $change->{time}; 154 | 155 | if( $r->name eq 'node' ) { 156 | my $lat = $r->getAttribute('lat'); 157 | my $lon = $r->getAttribute('lon'); 158 | next if $lon < $bbox[0] || $lon > $bbox[2] || $lat < $bbox[1] || $lat > $bbox[2]; 159 | $lat = floor($lat / $tile_size); 160 | #$lat = int(89/$tile_size) if $lat >= 90/$tile_size; 161 | $lon = floor($lon / $tile_size); 162 | #$lon = int(179/$tile_size) if $lon >= 180/$tile_size; 163 | 164 | my $key = "$lat,$lon,$changeset"; 165 | my $tile = $tiles{$key}; 166 | if( !defined($tile) ) { 167 | $tile = { 168 | lat => $lat, 169 | lon => $lon, 170 | changeset => $changeset, 171 | nodes_created => 0, 172 | nodes_modified => 0, 173 | nodes_deleted => 0 174 | }; 175 | $tiles{$key} = $tile; 176 | $tilesc++; 177 | } 178 | $tile->{'nodes_'.$state}++; 179 | 180 | if( $tilesc % 10**5 == 0 ) { 181 | flush_tiles(\%tiles, \%comments); 182 | %comments = (); 183 | %tiles = (); 184 | } 185 | } 186 | } 187 | } elsif( $r->nodeType == XML_READER_TYPE_END_ELEMENT ) { 188 | $state = '' if( $r->name eq 'delete' || $r->name eq 'modify' || $r->name eq 'create' ); 189 | } 190 | } 191 | flush_tiles(\%tiles, \%comments) if scalar %tiles; 192 | printf STDERR ", %d secs\n", tv_interval($clock) if $verbose; 193 | } 194 | 195 | sub flush_tiles {my ($tiles, $chs) = @_; 196 | printf STDERR "[Cnt/Mem: T=%d/%dk C=%d/%dk] ", scalar keys %{$tiles}, total_size($tiles)/1024, scalar keys %{$chs}, total_size($chs)/1024 if $verbose; 197 | 198 | my $sql_ch = <begin; 228 | eval { 229 | print STDERR "Writing changesets" if $verbose; 230 | for my $c (values %{$chs}) { 231 | $db->query($sql_ch, $c->{id}, $c->{time}, $c->{comment}, $c->{user_id}, $c->{username}, $c->{created_by}, 232 | $c->{nodes_created}, $c->{nodes_modified}, $c->{nodes_deleted}, 233 | $c->{ways_created}, $c->{ways_modified}, $c->{ways_deleted}, 234 | $c->{relations_created}, $c->{relations_modified}, $c->{relations_deleted}) or die $db->error; 235 | } 236 | 237 | print STDERR " and tiles" if $verbose; 238 | for my $t (values %{$tiles}) { 239 | $db->query($sql_t, $t->{lat}, $t->{lon}, $t->{changeset}, 240 | $t->{nodes_created}, $t->{nodes_modified}, $t->{nodes_deleted}); 241 | } 242 | $db->commit; 243 | }; 244 | if( $@ ) { 245 | eval { $db->rollback; }; 246 | die "Transaction failed: $@"; 247 | } 248 | print STDERR " OK" if $verbose; 249 | } 250 | 251 | sub get_changeset { 252 | my $changeset_id = shift; 253 | return unless $changeset_id =~ /^\d+$/; 254 | my $resp = $ua->get("http://api.openstreetmap.org/api/0.6/changeset/".$changeset_id); 255 | die "Failed to read changeset $changeset_id: ".$resp->status_line unless $resp->is_success; 256 | my $content = $resp->content; 257 | my $c = {}; 258 | $c->{id} = $changeset_id; 259 | $c->{comment} = decode_xml_entities($1) if $content =~ /k=["']comment['"]\s+v="([^"]+)"/; 260 | $c->{created_by} = decode_xml_entities($1) if $content =~ /k=["']created_by['"]\s+v="([^"]+)"/; 261 | $content =~ /\suser="([^"]+)"/; 262 | $c->{username} = decode_xml_entities($1) || ''; 263 | $content =~ /\suid="([^"]+)"/; 264 | $c->{user_id} = $1 || die("No uid in changeset $changeset_id"); 265 | $c->{nodes_created} = 0; $c->{nodes_modified} = 0; $c->{nodes_deleted} = 0; 266 | $c->{ways_created} = 0; $c->{ways_modified} = 0; $c->{ways_deleted} = 0; 267 | $c->{relations_created} = 0; $c->{relations_modified} = 0; $c->{relations_deleted} = 0; 268 | return $c; 269 | } 270 | 271 | sub decode_xml_entities { 272 | my $xml = shift; 273 | $xml =~ s/"/"/g; 274 | $xml =~ s/'/'/g; 275 | $xml =~ s/>/>/g; 276 | $xml =~ s/</query("drop table if exists ${dbprefix}tiles") or die $db->error; 283 | $db->query("drop table if exists ${dbprefix}changesets") or die $db->error; 284 | 285 | my $sql = <query($sql) or die $db->error; 301 | $sql = <query($sql) or die $db->error; 325 | print STDERR "Database tables were recreated.\n" if $verbose; 326 | } 327 | 328 | sub usage { 329 | my ($msg) = @_; 330 | print STDERR "$msg\n\n" if defined($msg); 331 | 332 | my $prog = basename($0); 333 | print STDERR << "EOF"; 334 | This script loads into whodidit database contents of a single 335 | osmChange file, or a series of replication diffs. In latter case 336 | it relies on a state.txt file in current directory. 337 | 338 | usage: $prog -i osc_file [-z] -d database -u user [-h host] [-p password] [-v] 339 | $prog -l url -d database -u user [-h host] [-p password] [-v] 340 | 341 | -i file : read a single osmChange file. 342 | -z : input file is gzip-compressed. 343 | -l url : base replication URL, must have a state file. 344 | -h host : DB host. 345 | -d database : DB database name. 346 | -u user : DB user name. 347 | -p password : DB password. 348 | -b bbox : BBox of a watched region (minlon,minlat,maxlon,maxlat) 349 | -t tilesize : size of a DB tile (default=$tile_size). 350 | -s state : name of state file (default=$state_file). 351 | -w wget : full path to wget tool (default=$wget). 352 | -c : drop and recreate DB tables. 353 | -v : display messages. 354 | 355 | EOF 356 | exit; 357 | } 358 | -------------------------------------------------------------------------------- /scripts/rss.php: -------------------------------------------------------------------------------- 1 | = $bbox[0] and t.lon <= $bbox[2] and t.lat >= $bbox[1] and t.lat <= $bbox[3] group by c.changeset_id order by c.change_time desc limit ".($filter ? '150' : '20'); 12 | $res = $db->query($sql); 13 | $bbox_str = $bbox[0]*$tile_size.','.$bbox[1]*$tile_size.','.($bbox[2]+1)*$tile_size.','.($bbox[3]+1)*$tile_size; 14 | //\thttp://openstreetmap.org/?box=yes&bbox=$bbox_str 15 | $latlon = 'lat='.(($bbox[3]+$bbox[1])*$tile_size/2).'&lon='.(($bbox[2]+$bbox[0])*$tile_size/2); 16 | print <<<"EOT" 17 | 18 | 19 | 20 | \tWhoDidIt Feed for BBOX [$bbox_str] 21 | \tWhoDidIt feed for BBOX [$bbox_str] 22 | \t$frontend_url?$latlon&zoom=12 23 | \tWhoDidIt 24 | \t60 25 | 26 | EOT; 27 | date_default_timezone_set('UTC'); 28 | $count = 20; 29 | while( $row = $res->fetch_assoc() ) { 30 | $susp = is_changeset_suspicious($row) ? '[!] ' : ''; 31 | if( $filter && !$susp ) continue; 32 | $untitled = !$row['comment'] || strlen($row['comment']) <= 2 || substr($row['comment'], 0, 5) == 'BBOX:'; 33 | print "\t\n"; 34 | print "\t\t${susp}User ".htmlspecialchars($row['user_name'])." has uploaded ".($untitled?'an untitled ':'a ')."changeset".($untitled?'':': "'.htmlspecialchars($row['comment']).'"')."\n"; 35 | print "\t\thttp://openstreetmap.org/browse/changeset/${row['changeset_id']}\n"; 36 | $date = strtotime($row['change_time']); 37 | $date_str = date(DATE_RSS, $date); 38 | print "\t\t$date_str\n"; 39 | $desc = "User ".htmlspecialchars($row['user_name'])." has uploaded a changeset in your watched area using ".htmlspecialchars($row['created_by']).", titled \"".htmlspecialchars($row['comment'])."\". Show it on WhoDidIt or in Achavi."; 40 | $desc .= '

Statistics:
    '; 41 | $desc .= '
  • Nodes: '.$row['nodes_created'].' created, '.$row['nodes_modified'].' modified, '.$row['nodes_deleted'].' deleted
  • '; 42 | $desc .= '
  • Ways: '.$row['ways_created'].' created, '.$row['ways_modified'].' modified, '.$row['ways_deleted'].' deleted
  • '; 43 | $desc .= '
  • Relations: '.$row['relations_created'].' created, '.$row['relations_modified'].' modified, '.$row['relations_deleted'].' deleted
'; 44 | print "\t\t".htmlspecialchars($desc)."\n"; 45 | print "\t
\n"; 46 | if( --$count <= 0 ) break; 47 | } 48 | print "
\n
"; 49 | ?> 50 | -------------------------------------------------------------------------------- /scripts/tiles.php: -------------------------------------------------------------------------------- 1 | 0 ) $aggregate = true; 30 | $age = isset($_REQUEST['age']) && preg_match('/^\d+$/', $_REQUEST['age']) ? $_REQUEST['age'] : 7; 31 | $age_sql = $changeset ? '' : " and date_add(c.change_time, interval $age day) > utc_timestamp()"; 32 | $bbox_query = $extent ? '' : " and t.lon >= $bbox[0] and t.lon <= $bbox[2] and t.lat >= $bbox[1] and t.lat <= $bbox[3]"; 33 | $editor = isset($_REQUEST['editor']) && strlen($_REQUEST['editor']) > 0 ? ' and c.created_by like \'%'.$db->escape_string($_REQUEST['editor']).'%\'' : ''; 34 | if( isset($_REQUEST['user']) && strlen($_REQUEST['user']) > 0 ) { 35 | $username = $_REQUEST['user']; 36 | $eqsign = '='; 37 | $aggregate = true; 38 | if( substr($username, 0, 1) == '!' ) { 39 | $ures = $db->query('select 1 from wdi_changesets where user_name = \''.$db->escape_string($username).'\' group by user_name limit 1'); 40 | if( $ures->num_rows == 0 ) { 41 | $username = substr($username, 1); 42 | $eqsign = '<>'; 43 | $aggregate = false; // it negates changeset filter, but this we can ignore 44 | } 45 | } 46 | $user = " and c.user_name $eqsign '".$db->escape_string($username).'\''; 47 | } else 48 | $user = ''; 49 | 50 | if( $aggregate && !$aggregate_only_filtered && isset($aggregate_db_limit) && $aggregate_db_limit > 0 ) { 51 | $test_sql = 'select 1 from wdi_tiles t, wdi_changesets c where c.changeset_id = t.changeset_id'. 52 | $bbox_query. 53 | $age_sql. 54 | $user. 55 | $editor. 56 | $changeset. 57 | ' limit '.$aggregate_db_limit; 58 | $tres = $db->query($test_sql); 59 | $aggregate = $tres->num_rows < $aggregate_db_limit; 60 | } 61 | 62 | $tile_limit = $aggregate ? $aggregate_tile_limit : $small_tile_limit; 63 | if( $tile_count > $tile_limit ) { 64 | print '{ "error" : "Area is too large, please zoom in" }'; 65 | exit; 66 | } 67 | 68 | if( $extent ) { 69 | // write bbox and exit 70 | $sql = 'select min(t.lon), min(t.lat), max(t.lon), max(t.lat) from wdi_tiles t, wdi_changesets c where c.changeset_id = t.changeset_id'.$age_sql.$user.$changeset; 71 | $res = $db->query($sql); 72 | if( $res === FALSE || $res->num_rows == 0 ) { 73 | print '{ "error" : "Cannot determine bounds" }'; 74 | exit; 75 | } 76 | $row = $res->fetch_array(); 77 | print '['; 78 | if( !$row[0] && !$row[3] ) { 79 | print '"no results"'; 80 | } else { 81 | for( $i = 0; $i < 4; $i++ ) { 82 | print ($row[$i] + ($i < 2 ? 0 : 1)) * $tile_size; 83 | if( $i < 3 ) print ', '; 84 | } 85 | } 86 | print ']'; 87 | exit; 88 | } 89 | 90 | if( $tile_count <= $small_tile_limit ) { 91 | $sql = 'select t.lat as rlat, t.lon as rlon'; 92 | } else { 93 | $sql = 'select floor(t.lat/10) as rlat, floor(t.lon/10) as rlon'; 94 | $tile_size *= 10; 95 | } 96 | $sql .= ', left(group_concat(t.changeset_id order by t.changeset_id desc separator \',\'),300) as changesets, sum(t.nodes_created) as nc, sum(t.nodes_modified) as nm, sum(t.nodes_deleted) as nd from wdi_tiles t, wdi_changesets c where c.changeset_id = t.changeset_id'. 97 | $bbox_query. 98 | $age_sql. 99 | $user. 100 | $editor. 101 | $changeset. 102 | ' group by rlat,rlon limit '.($db_tile_limit+1); 103 | 104 | $res = $db->query($sql); 105 | if( $res->num_rows > $db_tile_limit ) { 106 | print '{ "error" : "Too many tiles to display, please zoom in" }'; 107 | exit; 108 | } 109 | 110 | print '{ "type" : "FeatureCollection", "features" : ['."\n"; 111 | $first = true; 112 | while( $row = $res->fetch_assoc() ) { 113 | if( !$first ) print ",\n"; else $first = false; 114 | $lon = $row['rlon'] * $tile_size; 115 | $lat = $row['rlat'] * $tile_size; 116 | $poly = array( array($lon, $lat), array($lon+$tile_size, $lat), array($lon+$tile_size, $lat+$tile_size), array($lon, $lat+$tile_size), array($lon, $lat) ); 117 | $changesets = $row['changesets']; 118 | if( substr_count($changesets, ',') >= 10 ) { 119 | $changesets = implode(',', array_slice(explode(',', $changesets), 0, 10)); 120 | } 121 | $feature = array( 122 | 'type' => 'Feature', 123 | 'geometry' => array( 124 | 'type' => 'Polygon', 125 | 'coordinates' => array($poly) 126 | ), 127 | 'properties' => array( 128 | 'changesets' => $changesets, 129 | 'nodes_created' => $row['nc'], 130 | 'nodes_modified' => $row['nm'], 131 | 'nodes_deleted' => $row['nd'] 132 | ) 133 | ); 134 | print json_encode($feature); 135 | } 136 | print "\n] }"; 137 | ?> 138 | -------------------------------------------------------------------------------- /www/img/close.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zverik/whodidit/227cbdb1defeb73866314aa8e9a84dab1b2c767a/www/img/close.gif -------------------------------------------------------------------------------- /www/img/cloud-popup-relative.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zverik/whodidit/227cbdb1defeb73866314aa8e9a84dab1b2c767a/www/img/cloud-popup-relative.png -------------------------------------------------------------------------------- /www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | WHODIDIT: OpenStreetMap Changeset Analyzer 5 | 6 | 7 | 8 | 9 | 10 | 11 | 86 | 87 | 88 | 89 |
90 | WHO DID IT? 91 | 92 | Changeset: 93 | User: 94 | Age: 95 | 96 | 97 | 98 | 99 | 100 | ? 101 |
102 | 103 | 104 |
105 |
106 | 107 | 108 | -------------------------------------------------------------------------------- /www/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zverik/whodidit/227cbdb1defeb73866314aa8e9a84dab1b2c767a/www/loading.gif -------------------------------------------------------------------------------- /www/style.css: -------------------------------------------------------------------------------- 1 | div.olMap { 2 | z-index: 0; 3 | padding: 0 !important; 4 | margin: 0 !important; 5 | cursor: default; 6 | } 7 | 8 | div.olMapViewport { 9 | text-align: left; 10 | } 11 | 12 | div.olLayerDiv { 13 | -moz-user-select: none; 14 | -khtml-user-select: none; 15 | } 16 | 17 | .olLayerGoogleCopyright { 18 | left: 2px; 19 | bottom: 2px; 20 | } 21 | .olLayerGoogleV3.olLayerGoogleCopyright { 22 | right: auto !important; 23 | } 24 | .olLayerGooglePoweredBy { 25 | left: 2px; 26 | bottom: 15px; 27 | } 28 | .olLayerGoogleV3.olLayerGooglePoweredBy { 29 | bottom: 15px !important; 30 | } 31 | .olControlAttribution { 32 | font-size: smaller; 33 | right: 3px; 34 | bottom: 4.5em; 35 | position: absolute; 36 | display: block; 37 | } 38 | .olControlScale { 39 | right: 3px; 40 | bottom: 3em; 41 | display: block; 42 | position: absolute; 43 | font-size: smaller; 44 | } 45 | .olControlScaleLine { 46 | display: block; 47 | position: absolute; 48 | left: 10px; 49 | bottom: 15px; 50 | font-size: xx-small; 51 | } 52 | .olControlScaleLineBottom { 53 | border: solid 2px black; 54 | border-bottom: none; 55 | margin-top:-2px; 56 | text-align: center; 57 | } 58 | .olControlScaleLineTop { 59 | border: solid 2px black; 60 | border-top: none; 61 | text-align: center; 62 | } 63 | 64 | .olControlPermalink { 65 | right: 3px; 66 | bottom: 1.5em; 67 | display: block; 68 | position: absolute; 69 | font-size: smaller; 70 | } 71 | 72 | div.olControlMousePosition { 73 | bottom: 0; 74 | right: 3px; 75 | display: block; 76 | position: absolute; 77 | font-family: Arial; 78 | font-size: smaller; 79 | } 80 | 81 | .olControlOverviewMapContainer { 82 | position: absolute; 83 | bottom: 0; 84 | right: 0; 85 | } 86 | 87 | .olControlOverviewMapElement { 88 | padding: 10px 18px 10px 10px; 89 | background-color: #00008B; 90 | -moz-border-radius: 1em 0 0 0; 91 | } 92 | 93 | .olControlOverviewMapMinimizeButton, 94 | .olControlOverviewMapMaximizeButton { 95 | height: 18px; 96 | width: 18px; 97 | right: 0; 98 | bottom: 80px; 99 | cursor: pointer; 100 | } 101 | 102 | .olControlOverviewMapExtentRectangle { 103 | overflow: hidden; 104 | background-image: url("img/blank.gif"); 105 | cursor: move; 106 | border: 2px dotted red; 107 | } 108 | .olControlOverviewMapRectReplacement { 109 | overflow: hidden; 110 | cursor: move; 111 | background-image: url("img/overview_replacement.gif"); 112 | background-repeat: no-repeat; 113 | background-position: center; 114 | } 115 | 116 | .olLayerGeoRSSDescription { 117 | float:left; 118 | width:100%; 119 | overflow:auto; 120 | font-size:1.0em; 121 | } 122 | .olLayerGeoRSSClose { 123 | float:right; 124 | color:gray; 125 | font-size:1.2em; 126 | margin-right:6px; 127 | font-family:sans-serif; 128 | } 129 | .olLayerGeoRSSTitle { 130 | float:left;font-size:1.2em; 131 | } 132 | 133 | .olPopupContent { 134 | padding:5px; 135 | overflow: auto; 136 | } 137 | 138 | .olControlNavigationHistory { 139 | background-image: url("img/navigation_history.png"); 140 | background-repeat: no-repeat; 141 | width: 24px; 142 | height: 24px; 143 | 144 | } 145 | .olControlNavigationHistoryPreviousItemActive { 146 | background-position: 0 0; 147 | } 148 | .olControlNavigationHistoryPreviousItemInactive { 149 | background-position: 0 -24px; 150 | } 151 | .olControlNavigationHistoryNextItemActive { 152 | background-position: -24px 0; 153 | } 154 | .olControlNavigationHistoryNextItemInactive { 155 | background-position: -24px -24px; 156 | } 157 | 158 | div.olControlSaveFeaturesItemActive { 159 | background-image: url(img/save_features_on.png); 160 | background-repeat: no-repeat; 161 | background-position: 0 1px; 162 | } 163 | div.olControlSaveFeaturesItemInactive { 164 | background-image: url(img/save_features_off.png); 165 | background-repeat: no-repeat; 166 | background-position: 0 1px; 167 | } 168 | 169 | .olHandlerBoxZoomBox { 170 | border: 2px solid red; 171 | position: absolute; 172 | background-color: white; 173 | opacity: 0.50; 174 | font-size: 1px; 175 | filter: alpha(opacity=50); 176 | } 177 | .olHandlerBoxSelectFeature { 178 | border: 2px solid blue; 179 | position: absolute; 180 | background-color: white; 181 | opacity: 0.50; 182 | font-size: 1px; 183 | filter: alpha(opacity=50); 184 | } 185 | 186 | .olControlPanPanel { 187 | top: 10px; 188 | left: 5px; 189 | } 190 | 191 | .olControlPanPanel div { 192 | background-image: url(img/pan-panel.png); 193 | height: 18px; 194 | width: 18px; 195 | cursor: pointer; 196 | position: absolute; 197 | } 198 | 199 | .olControlPanPanel .olControlPanNorthItemInactive { 200 | top: 0; 201 | left: 9px; 202 | background-position: 0 0; 203 | } 204 | .olControlPanPanel .olControlPanSouthItemInactive { 205 | top: 36px; 206 | left: 9px; 207 | background-position: 18px 0; 208 | } 209 | .olControlPanPanel .olControlPanWestItemInactive { 210 | position: absolute; 211 | top: 18px; 212 | left: 0; 213 | background-position: 0 18px; 214 | } 215 | .olControlPanPanel .olControlPanEastItemInactive { 216 | top: 18px; 217 | left: 18px; 218 | background-position: 18px 18px; 219 | } 220 | 221 | .olControlZoomPanel { 222 | top: 71px; 223 | left: 14px; 224 | } 225 | 226 | .olControlZoomPanel div { 227 | background-image: url(img/zoom-panel.png); 228 | position: absolute; 229 | height: 18px; 230 | width: 18px; 231 | cursor: pointer; 232 | } 233 | 234 | .olControlZoomPanel .olControlZoomInItemInactive { 235 | top: 0; 236 | left: 0; 237 | background-position: 0 0; 238 | } 239 | 240 | .olControlZoomPanel .olControlZoomToMaxExtentItemInactive { 241 | top: 18px; 242 | left: 0; 243 | background-position: 0 -18px; 244 | } 245 | 246 | .olControlZoomPanel .olControlZoomOutItemInactive { 247 | top: 36px; 248 | left: 0; 249 | background-position: 0 18px; 250 | } 251 | 252 | /* 253 | * When a potential text is bigger than the image it move the image 254 | * with some headers (closes #3154) 255 | */ 256 | .olControlPanZoomBar div { 257 | font-size: 1px; 258 | } 259 | 260 | .olPopupCloseBox { 261 | background: url("img/close.gif") no-repeat; 262 | cursor: pointer; 263 | } 264 | 265 | .olFramedCloudPopupContent { 266 | padding: 5px; 267 | overflow: auto; 268 | } 269 | 270 | .olControlNoSelect { 271 | -moz-user-select: none; 272 | -khtml-user-select: none; 273 | } 274 | 275 | .olImageLoadError { 276 | background-color: pink; 277 | opacity: 0.5; 278 | filter: alpha(opacity=50); /* IE */ 279 | } 280 | 281 | /** 282 | * Cursor styles 283 | */ 284 | 285 | .olCursorWait { 286 | cursor: wait; 287 | } 288 | .olDragDown { 289 | cursor: move; 290 | } 291 | .olDrawBox { 292 | cursor: crosshair; 293 | } 294 | .olControlDragFeatureOver { 295 | cursor: move; 296 | } 297 | .olControlDragFeatureActive.olControlDragFeatureOver.olDragDown { 298 | cursor: -moz-grabbing; 299 | } 300 | 301 | /** 302 | * Layer switcher 303 | */ 304 | .olControlLayerSwitcher { 305 | position: absolute; 306 | top: 25px; 307 | right: 0; 308 | width: 20em; 309 | font-family: sans-serif; 310 | font-weight: bold; 311 | margin-top: 3px; 312 | margin-left: 3px; 313 | margin-bottom: 3px; 314 | font-size: smaller; 315 | color: white; 316 | background-color: transparent; 317 | } 318 | 319 | .olControlLayerSwitcher .layersDiv { 320 | padding-top: 5px; 321 | padding-left: 10px; 322 | padding-bottom: 5px; 323 | padding-right: 10px; 324 | background-color: darkblue; 325 | } 326 | 327 | .olControlLayerSwitcher .layersDiv .baseLbl, 328 | .olControlLayerSwitcher .layersDiv .dataLbl { 329 | margin-top: 3px; 330 | margin-left: 3px; 331 | margin-bottom: 3px; 332 | } 333 | 334 | .olControlLayerSwitcher .layersDiv .baseLayersDiv, 335 | .olControlLayerSwitcher .layersDiv .dataLayersDiv { 336 | padding-left: 10px; 337 | } 338 | 339 | .olControlLayerSwitcher .maximizeDiv, 340 | .olControlLayerSwitcher .minimizeDiv { 341 | width: 18px; 342 | height: 18px; 343 | top: 5px; 344 | right: 0; 345 | cursor: pointer; 346 | } 347 | 348 | .olBingAttribution { 349 | color: #DDD; 350 | } 351 | .olBingAttribution.road { 352 | color: #333; 353 | } 354 | 355 | .olGoogleAttribution.hybrid, .olGoogleAttribution.satellite { 356 | color: #EEE; 357 | } 358 | .olGoogleAttribution { 359 | color: #333; 360 | } 361 | span.olGoogleAttribution a { 362 | color: #77C; 363 | } 364 | span.olGoogleAttribution.hybrid a, span.olGoogleAttribution.satellite a { 365 | color: #EEE; 366 | } 367 | 368 | /** 369 | * Editing and navigation icons. 370 | * (using the editing_tool_bar.png sprint image) 371 | */ 372 | .olControlNavToolbar , 373 | .olControlEditingToolbar { 374 | margin: 5px 5px 0 0; 375 | } 376 | .olControlNavToolbar div, 377 | .olControlEditingToolbar div { 378 | background-image: url("img/editing_tool_bar.png"); 379 | background-repeat: no-repeat; 380 | margin: 0 0 5px 5px; 381 | width: 24px; 382 | height: 22px; 383 | cursor: pointer 384 | } 385 | /* positions */ 386 | .olControlEditingToolbar { 387 | right: 0; 388 | top: 0; 389 | } 390 | .olControlNavToolbar { 391 | top: 295px; 392 | left: 9px; 393 | } 394 | /* layouts */ 395 | .olControlEditingToolbar div { 396 | float: right; 397 | } 398 | /* individual controls */ 399 | .olControlNavToolbar .olControlNavigationItemInactive, 400 | .olControlEditingToolbar .olControlNavigationItemInactive { 401 | background-position: -103px -1px; 402 | } 403 | .olControlNavToolbar .olControlNavigationItemActive , 404 | .olControlEditingToolbar .olControlNavigationItemActive { 405 | background-position: -103px -24px; 406 | } 407 | .olControlNavToolbar .olControlZoomBoxItemInactive { 408 | background-position: -128px -1px; 409 | } 410 | .olControlNavToolbar .olControlZoomBoxItemActive { 411 | background-position: -128px -24px; 412 | } 413 | .olControlEditingToolbar .olControlDrawFeaturePointItemInactive { 414 | background-position: -77px -1px; 415 | } 416 | .olControlEditingToolbar .olControlDrawFeaturePointItemActive { 417 | background-position: -77px -24px; 418 | } 419 | .olControlEditingToolbar .olControlDrawFeaturePathItemInactive { 420 | background-position: -51px -1px; 421 | } 422 | .olControlEditingToolbar .olControlDrawFeaturePathItemActive { 423 | background-position: -51px -24px; 424 | } 425 | .olControlEditingToolbar .olControlDrawFeaturePolygonItemInactive{ 426 | background-position: -26px -1px; 427 | } 428 | .olControlEditingToolbar .olControlDrawFeaturePolygonItemActive { 429 | background-position: -26px -24px; 430 | } 431 | 432 | div.olControlZoom { 433 | position: absolute; 434 | top: 8px; 435 | left: 8px; 436 | background: rgba(255,255,255,0.4); 437 | border-radius: 4px; 438 | padding: 2px; 439 | } 440 | div.olControlZoom a { 441 | display: block; 442 | margin: 1px; 443 | padding: 0; 444 | color: white; 445 | font-size: 18px; 446 | font-family: 'Lucida Grande', Verdana, Geneva, Lucida, Arial, Helvetica, sans-serif; 447 | font-weight: bold; 448 | text-decoration: none; 449 | text-align: center; 450 | height: 22px; 451 | width:22px; 452 | line-height: 19px; 453 | background: #130085; /* fallback for IE - IE6 requires background shorthand*/ 454 | background: rgba(0, 60, 136, 0.5); 455 | filter: alpha(opacity=80); 456 | } 457 | div.olControlZoom a:hover { 458 | background: #130085; /* fallback for IE */ 459 | background: rgba(0, 60, 136, 0.7); 460 | filter: alpha(opacity=100); 461 | } 462 | @media only screen and (max-width: 600px) { 463 | div.olControlZoom a:hover { 464 | background: rgba(0, 60, 136, 0.5); 465 | } 466 | } 467 | a.olControlZoomIn { 468 | border-radius: 4px 4px 0 0; 469 | } 470 | a.olControlZoomOut { 471 | border-radius: 0 0 4px 4px; 472 | } 473 | 474 | 475 | /** 476 | * Animations 477 | */ 478 | 479 | .olLayerGrid .olTileImage { 480 | -webkit-transition: opacity 0.2s linear; 481 | -moz-transition: opacity 0.2s linear; 482 | -o-transition: opacity 0.2s linear; 483 | transition: opacity 0.2s linear; 484 | } 485 | -------------------------------------------------------------------------------- /www/wdi_guide.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zverik/whodidit/227cbdb1defeb73866314aa8e9a84dab1b2c767a/www/wdi_guide.gif -------------------------------------------------------------------------------- /www/whodidit.js: -------------------------------------------------------------------------------- 1 | // WHODIDID Frontend JS. Written by Ilya Zverev, licensed WTFPL 2 | 3 | var map; 4 | var popup; 5 | var vectorLayer; 6 | var permalink; 7 | 8 | var changeset; 9 | var username; 10 | var age; 11 | var editor; 12 | var defaultage = 7; // should be equal to the default age in tiles.php 13 | 14 | var cookieName = '_wdi_location'; // comment out to not remember last location 15 | var epsg4326 = new OpenLayers.Projection("EPSG:4326"); //WGS 1984 projection 16 | 17 | function init() { 18 | populateAgeBox(); 19 | var queryString = parseQueryString(); 20 | if( queryString.changeset ) setChangeset(queryString.changeset); 21 | if( queryString.user ) setUser(queryString.user); 22 | if( queryString.editor ) editor = queryString.editor; 23 | setAge(queryString.age ? queryString.age : defaultage); 24 | 25 | map = new OpenLayers.Map('map', {displayProjection: epsg4326}); 26 | 27 | map.addLayer(new OpenLayers.Layer.OSM()); //Standard mapnik tiles 28 | map.baseLayer.attribution = '© OpenStreetMap contributors'; 29 | 30 | permalink = new OpenLayers.Control.Permalink('permalink', null, {createParams: myCreateArgs}); 31 | map.addControls([ 32 | permalink, 33 | new OpenLayers.Control.MousePosition({numDigits: 3}) 34 | ]); 35 | 36 | projectTo = map.getProjectionObject(); //The map projection (Spherical Mercator) 37 | 38 | // boxLayer is used to draw rectangles, which are bounds for a RSS feed. 39 | boxLayer = new OpenLayers.Layer.Vector('BBOX'); 40 | map.addLayer(boxLayer); 41 | boxControl = new OpenLayers.Control.DrawFeature(boxLayer, OpenLayers.Handler.RegularPolygon, {featureAdded: featureAdded, handlerOptions: { 42 | sides: 4, 43 | irregular: true 44 | }}); 45 | 46 | map.addControl(boxControl); 47 | 48 | // Styling for tile layer 49 | var context = { 50 | getColor: function(feature) { 51 | if( feature.attributes.nodes_deleted > 0 && feature.attributes.nodes_modified + feature.attributes.nodes_created == 0 ) return 'red'; 52 | if( feature.attributes.nodes_deleted > 0 && (+feature.attributes.nodes_modified) + (+feature.attributes.nodes_created) < feature.attributes.nodes_deleted * 40 ) return 'yellow'; 53 | if( (+feature.attributes.nodes_modified) > 40 ) return 'yellow'; 54 | return '#7f7'; 55 | } 56 | }; 57 | var template = { 58 | fillColor: "${getColor}", 59 | fillOpacity: 0.4, 60 | strokeColor: '#333', 61 | strokeOpacity: 0.4 62 | }; 63 | var style = new OpenLayers.Style(template, {context: context}); 64 | 65 | vectorLayer = new OpenLayers.Layer.Vector("WhoDidIt Tiles", { 66 | strategies: [new OpenLayers.Strategy.BBOX({resFactor: 2.0, ratio: 1.3})], 67 | protocol: new OpenLayers.Protocol.HTTP({ 68 | url: scripts + 'tiles.php', 69 | params: getParams(), 70 | format: new OpenLayers.Format.GeoJSON(), 71 | handleRead: handleMessageRead, 72 | read: startMessageRead 73 | }), 74 | styleMap: new OpenLayers.StyleMap({'default': style, 'select': OpenLayers.Feature.Vector.style["select"]}), 75 | projection: epsg4326 76 | }); 77 | 78 | map.addLayer(vectorLayer); 79 | 80 | // Set centre. The location of the last lat lon to be processed. 81 | if( !map.getCenter() ) 82 | restoreLocation(); 83 | if (!map.getCenter()) { 84 | var zoom=4; 85 | var lonLat = new OpenLayers.LonLat(32, 50).transform(epsg4326, projectTo); 86 | map.setCenter (lonLat, zoom); 87 | } 88 | 89 | // Add a selector control to the vectorLayer with popup functions 90 | var selector = new OpenLayers.Control.SelectFeature(vectorLayer, { onSelect: createPopup, onUnselect: destroyPopup }); 91 | 92 | function createPopup(feature) { 93 | var nodeinfo = feature.attributes.nodes_created + ' nodes created, ' + feature.attributes.nodes_modified + ' modified, ' + feature.attributes.nodes_deleted + ' deleted in this tile.
'; 94 | var bbox = feature.geometry.bounds.clone().transform(projectTo, epsg4326); 95 | var josmlink = '
Open in JOSM'; 96 | popup = new OpenLayers.Popup.FramedCloud("pop", 97 | feature.geometry.getBounds().getCenterLonLat(), 98 | null, 99 | '
' + nodeinfo + 'Changesets: ' + feature.attributes.changesets + josmlink + '
', 100 | null, 101 | true, 102 | function() { selector.unselectAll(); } 103 | ); 104 | // Send ajax request to get changeset information 105 | var request = OpenLayers.Request.GET({ 106 | url: scripts + 'changeset.php', 107 | params: { id: feature.attributes.changesets }, 108 | callback: function(req) { 109 | var json = new OpenLayers.Format.JSON(); 110 | var changesets = json.read(req.responseText); 111 | var html = '
' + nodeinfo + '
'; 112 | var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; 113 | for( i = 0; i < changesets.length; i++ ) { 114 | var ch = changesets[i]; 115 | html += '
'; 116 | var color = ch['suspicious'] ? 'red' : 'green'; 117 | var date_str = months[ch['change_time'].substr(5,2)-1] + ' ' + ch['change_time'].substr(8,2); 118 | html += '' + date_str + ''; 119 | html += ': changeset'; 120 | html += ' [A]'; 121 | html += ' [F]'; 122 | html += ' by user ' + htmlEscape(ch['user_name']) + ''; 123 | html += ' [F]'; 124 | html += '. Nodes:'+ch['nodes_created']+''+ch['nodes_modified']+''+ch['nodes_deleted']+''; 125 | html += ' Ways:'+ch['ways_created']+''+ch['ways_modified']+''+ch['ways_deleted']+''; 126 | html += ' Rels:'+ch['relations_created']+''+ch['relations_modified']+''+ch['relations_deleted']+''; 127 | if( ch['comment'] && ch['comment'].length > 2 && ch['comment'].substring(0,5) != 'BBOX:' ) 128 | html += '
' + htmlEscape(ch['comment']) + '
'; 129 | html += '
'; 130 | } 131 | html += josmlink + '
'; 132 | feature.popup.setContentHTML(html); 133 | } 134 | }); 135 | feature.popup = popup; 136 | popup.feature = feature; 137 | map.addPopup(popup); 138 | } 139 | 140 | function destroyPopup(feature) { 141 | if( feature.popup) { 142 | map.removePopup(feature.popup); 143 | popup.feature = null; 144 | feature.popup.destroy(); 145 | feature.popup = null; 146 | popup = null; 147 | } 148 | } 149 | 150 | // When map is dragged, all features are redrawn, but popup stays and becomes unclosable. This fixes it. 151 | vectorLayer.events.register('beforefeaturesremoved', ' ', function() { if(popup) destroyPopup(popup.feature); }); 152 | //vectorLayer.events.register('refresh', null, function() { document.getElementById('loading').style.visibility = 'inherit'; }); 153 | 154 | selector.handlers.feature.stopDown = false; 155 | map.addControl(selector); 156 | selector.activate(); 157 | 158 | // Get latest changeset date 159 | OpenLayers.Request.GET({ 160 | url: scripts + 'changeset.php', 161 | params: { latest: 1 }, 162 | callback: function(req) { 163 | var json = new OpenLayers.Format.JSON(); 164 | var changesets = json.read(req.responseText); 165 | if( changesets.length > 0 ) { 166 | document.getElementById('whodidit').title = 'Last changeset from ' + changesets[0]['change_time'] + ' UTC'; 167 | } 168 | } 169 | }); 170 | 171 | // Add &show=1 to zoom on user/changeset tiles 172 | if( queryString.show ) { 173 | zoomToTiles(); 174 | } 175 | 176 | // Remember last shown location in cookies 177 | map.events.register("moveend", map, saveLocation); 178 | saveLocation(); 179 | } 180 | 181 | /* -------------------------- END OF INIT() ------------------------------------------- */ 182 | 183 | function parseQueryString() { 184 | var query_string = {}; 185 | var query = window.location.search.substring(1); 186 | var vars = query.split("&"); 187 | for( var i = 0; i < vars.length; i++ ) { 188 | var pair = vars[i].split("="); 189 | pair[1] = decodeURIComponent(pair[1]); 190 | if (typeof query_string[pair[0]] === "undefined") { 191 | // If first entry with this name 192 | query_string[pair[0]] = pair[1]; 193 | } else if (typeof query_string[pair[0]] === "string") { 194 | // If second entry with this name 195 | var arr = [ query_string[pair[0]], pair[1] ]; 196 | query_string[pair[0]] = arr; 197 | } else { 198 | // If third or later entry with this name 199 | query_string[pair[0]].push(pair[1]); 200 | } 201 | } 202 | return query_string; 203 | } 204 | 205 | // Fiddle with permalink's url parameters 206 | function myCreateArgs() { 207 | var args = OpenLayers.Control.Permalink.prototype.createParams.apply(this, arguments); 208 | if( changeset ) args['changeset'] = changeset; else delete args['changeset']; 209 | if( username ) args['user'] = username; else delete args['user']; 210 | if( editor ) args['editor'] = editor; else delete args['editor']; 211 | if( age != defaultage ) args['age'] = age; else delete args['age']; 212 | delete args['show']; 213 | return args; 214 | } 215 | 216 | // Overriding protocol to display error message 217 | function startMessageRead(options) { 218 | document.getElementById('message').style.visibility = 'hidden'; 219 | document.getElementById('loading').style.visibility = 'inherit'; 220 | return OpenLayers.Protocol.HTTP.prototype.read.apply(this, arguments); 221 | } 222 | 223 | function handleMessageRead(resp, options) { 224 | var request = resp.priv; 225 | document.getElementById('loading').style.visibility = 'hidden'; 226 | document.getElementById('message').style.visibility = 'hidden'; 227 | if( request.status >= 200 && request.status < 300 ) { 228 | var doc = request.responseText; 229 | if( doc.indexOf('error') > 0 ) { 230 | var json = new OpenLayers.Format.JSON(); 231 | var error = json.read(doc); 232 | document.getElementById('message').innerHTML = error.error; 233 | document.getElementById('message').style.visibility = 'inherit'; 234 | } 235 | } else { 236 | document.getElementById('message').innerHTML = 'Failed to acquire tiles'; 237 | document.getElementById('message').style.visibility = 'inherit'; 238 | } 239 | OpenLayers.Protocol.HTTP.prototype.handleRead.apply(this, arguments); 240 | } 241 | 242 | function htmlEscape(str) { 243 | return String(str) 244 | .replace(/&/g, '&') 245 | .replace(/"/g, '"') 246 | .replace(/'/g, ''') 247 | .replace(//g, '>'); 249 | } 250 | 251 | // This is used in tiles ajax request 252 | function getParams() { 253 | return { 254 | 'age': age, 255 | 'changeset': changeset, 256 | 'editor': editor, 257 | 'user': username 258 | }; 259 | } 260 | 261 | function setChangeset(ch) { 262 | clearFilter(); 263 | if( ch ) { 264 | document.getElementById('vuser').style.visibility = 'hidden'; 265 | document.getElementById('tchangeset').value = ch; 266 | document.getElementById('bchangeset').value = 'Clear'; 267 | document.getElementById('tchangeset').disabled = true; 268 | changeset = ch; 269 | username = ''; 270 | document.getElementById('vwhere').style.visibility = 'inherit'; 271 | } 272 | updateParams(); 273 | } 274 | 275 | function setUser(ch) { 276 | clearFilter(); 277 | if( ch ) { 278 | document.getElementById('vchangeset').style.visibility = 'hidden'; 279 | document.getElementById('tuser').value = ch; 280 | document.getElementById('buser').value = 'Clear'; 281 | document.getElementById('tuser').disabled = true; 282 | changeset = ''; 283 | username = ch; 284 | document.getElementById('vwhere').style.visibility = 'inherit'; 285 | } 286 | updateParams(); 287 | } 288 | 289 | function setAge(ch) { 290 | age = ch; 291 | var sel = document.getElementById('tage'); 292 | var s; 293 | for( i = sel.options.length-1; i >= 0; i-- ) { 294 | if( sel.options[i].value - age >= 0 ) 295 | s = i; 296 | } 297 | sel.selectedIndex = s; 298 | updateParams(); 299 | } 300 | 301 | function apply(what) { 302 | if( changeset || username ) { 303 | clearFilter(); 304 | } else if( what == 'changeset' ) { 305 | setChangeset(document.getElementById('tchangeset').value); 306 | } else if( what == 'user' ) { 307 | setUser(document.getElementById('tuser').value); 308 | } 309 | } 310 | 311 | function clearFilter() { 312 | document.getElementById('tchangeset').disabled = false; 313 | document.getElementById('tchangeset').value = ''; 314 | document.getElementById('bchangeset').value = 'Apply'; 315 | document.getElementById('vchangeset').style.visibility = 'inherit'; 316 | changeset = ''; 317 | document.getElementById('tuser').disabled = false; 318 | document.getElementById('tuser').value = ''; 319 | document.getElementById('buser').value = 'Apply'; 320 | document.getElementById('vuser').style.visibility = 'inherit'; 321 | username = ''; 322 | document.getElementById('vwhere').style.visibility = 'hidden'; 323 | updateParams(); 324 | } 325 | 326 | function updateParams() { 327 | if( vectorLayer ) { 328 | vectorLayer.protocol.options.params = getParams(); 329 | vectorLayer.refresh({ 330 | force: true, 331 | params: getParams() 332 | }); 333 | permalink.updateLink(); 334 | } 335 | } 336 | 337 | // Callback methods for drawing box for a RSS feed 338 | function startDrawBBOX() { 339 | if( boxLayer.features.length > 0 ) { 340 | boxLayer.removeAllFeatures(); 341 | document.getElementById('brss').value='Get RSS link'; 342 | document.getElementById('rssurlbox').style.visibility='hidden'; 343 | } else { 344 | boxControl.activate(); 345 | document.getElementById('brss').value='Draw a box'; 346 | } 347 | } 348 | 349 | function featureAdded(feature) { 350 | boxControl.deactivate(); 351 | document.getElementById('brss').value='Clear RSS link'; 352 | document.getElementById('rssurlbox').style.visibility='inherit'; 353 | var bboxstr = feature.geometry.bounds.transform(projectTo, epsg4326).toBBOX(); 354 | document.getElementById('rssurl').href=scripts + 'rss.php?bbox=' + bboxstr; 355 | document.getElementById('rssfurl').href=scripts + 'rss.php?filter=1&bbox=' + bboxstr; 356 | } 357 | 358 | function zoomToTiles() { 359 | // zooming to tiles obviously calls for ajax request 360 | var request = OpenLayers.Request.GET({ 361 | url: scripts + 'tiles.php?extent=1', 362 | params: getParams(), 363 | callback: function(req) { 364 | var json = new OpenLayers.Format.JSON(); 365 | var bbox = json.read(req.responseText); 366 | if( bbox.length == 4 ) { 367 | var bounds = new OpenLayers.Bounds(bbox[0], bbox[1], bbox[2], bbox[3]); 368 | map.zoomToExtent(bounds.transform(epsg4326, projectTo)); 369 | } 370 | } 371 | }); 372 | } 373 | 374 | function saveLocation() { 375 | if( !cookieName ) return; 376 | var lonlat = map.getCenter().transform(map.getProjectionObject(), epsg4326); 377 | var zoom = map.getZoom(); 378 | var expiry = new Date(); 379 | expiry.setYear(expiry.getFullYear() + 10); 380 | document.cookie = cookieName + '=' + [lonlat.lon, lonlat.lat, zoom].join("|") + ';expires=' + expiry.toGMTString(); 381 | } 382 | 383 | function restoreLocation() { 384 | if( !cookieName ) return; 385 | if( document.cookie.length > 0 ) { 386 | var start = document.cookie.indexOf(cookieName + '='); 387 | if( start >= 0 ) { 388 | start += cookieName.length + 1; 389 | var end = document.cookie.indexOf(';', start); 390 | if( end < 0 ) end = document.cookie.length; 391 | var location = document.cookie.substring(start, end).split('|'); 392 | if( location.length == 3 ) { 393 | var lon = parseFloat(location[0]); 394 | var lat = parseFloat(location[1]); 395 | var zoom = parseFloat(location[2]); 396 | map.setCenter(new OpenLayers.LonLat(lon, lat).transform(epsg4326, map.getProjectionObject()), zoom); 397 | } 398 | } 399 | } 400 | } 401 | 402 | function populateAgeBox() { 403 | var sel = document.getElementById('tage'); 404 | sel.options.length = 0; 405 | sel.options[sel.options.length] = new Option('day', 1); 406 | sel.options[sel.options.length] = new Option('week', 7); 407 | sel.options[sel.options.length] = new Option('month', 31); 408 | sel.options[sel.options.length] = new Option('half a year', 187); 409 | sel.options[sel.options.length] = new Option('eternity', 1000); 410 | } 411 | 412 | function round2(n) { 413 | return Math.round(n*1000)/1000; 414 | } 415 | --------------------------------------------------------------------------------