File Coverage

blib/lib/Net/Flickr/Geo/ModestMaps.pm
Criterion Covered Total %
statement 6 6 100.0
branch n/a
condition n/a
subroutine 2 2 100.0
pod n/a
total 8 8 100.0


line stmt bran cond sub pod time code
1 1     1   2290 use strict;
  1         2  
  1         54  
2             # $Id: ModestMaps.pm,v 1.63 2008/08/03 17:08:39 asc Exp $
3              
4             package Net::Flickr::Geo::ModestMaps;
5 1     1   5 use base qw(Net::Flickr::Geo);
  1         1  
  1         86  
6              
7             $Net::Flickr::Geo::ModestMaps::VERSION = '0.72';
8              
9             =head1 NAME
10              
11             Net::Flickr::Geo::ModestMaps - tools for working with geotagged Flickr photos and Modest Maps
12              
13             =head1 SYNOPSIS
14              
15             my %opts = ();
16             getopts('c:s:', \%opts);
17              
18             #
19             # Defaults
20             #
21              
22             my $cfg = Config::Simple->new($opts{'c'});
23              
24             #
25             # Atkinson dithering is hawt but takes a really long
26             # time...
27             #
28              
29             $cfg->param("modestmaps.filter", "atkinson");
30             $cfg->param("modestmaps.timeout", (45 * 60));
31              
32             #
33             # Let's say all but one of your photos are in the center of
34             # Paris and the last one is at the airport. If you try to render
35             # a 'poster style' (that is all the tiles for the bounding box
36             # containing those points at street level) map you will make
37             # your computer cry...
38             #
39              
40             $cfg->param("pinwin.skip_photos", [506934069]);
41              
42             #
43             # I CAN HAS MAPZ?
44             #
45              
46             my $fl = Net::Flickr::Geo::ModestMaps->new($cfg);
47             $fl->log()->add(Log::Dispatch::Screen->new('name' => 'scr', min_level => 'info'));
48              
49             my $map_data = $fl->mk_poster_map_for_photoset($opts{'s'});
50              
51             #
52             # returns stuff like :
53             #
54             # {
55             # 'url' => 'http://127.0.0.1:9999/?provider=YAHOO_AERIAL&marker=yadda yadda yadda',
56             # 'image-height' => '8528',
57             # 'marker-484080715' => '5076,5606,4919,5072,500,375',
58             # 'marker-506435771' => '5256,4768,5099,542,500,375',
59             # 'path' => '/tmp/dkl0o7uxjY.jpg',
60             # 'image-width' => '6656',
61             # }
62             #
63              
64             my $results = $fl->upload_poster_map($map_data->{'path'});
65              
66             #
67             # returns stuff like :
68             #
69             # [
70             # ['/tmp/GGsf4552h.jpg', '99999992'],
71             # ['/tmp/kosfGgsfdh.jpg', '99999254'],
72             # ['/tmp/h354jF590.jpg', '999984643'],
73             # [ and so on... ]
74             # ];
75             #
76              
77             =head1 DESCRIPTION
78              
79             Tools for working with geotagged Flickr photos and the Modest Maps ws-pinwin HTTP service.
80              
81              
82             =cut
83              
84             =head1 OPTIONS
85              
86             Options are passed to Net::Flickr::Backup using a Config::Simple object or
87             a valid Config::Simple config file. Options are grouped by "block".
88              
89             =head2 flickr
90              
91             =over 4
92              
93             =item * B
94              
95             String. I
96              
97             A valid Flickr API key.
98              
99             =item * B
100              
101             String. I
102              
103             A valid Flickr Auth API secret key.
104              
105             =item * B
106              
107             String. I
108              
109             A valid Flickr Auth API token.
110              
111             The B defines which XML/XPath handler to use to process API responses.
112              
113             =over 4
114              
115             =item * B
116              
117             Use XML::LibXML.
118              
119             =item * B
120              
121             Use XML::XPath.
122              
123             =back
124              
125             =back
126              
127             =head2 pinwin
128              
129             =item * B
130              
131             The height of the background map on which the pinwin/thumbnail will be
132             placed.
133              
134             Default is 1024.
135              
136             =item * B
137              
138             The width of the background map on which the pinwin/thumbnail will be
139             placed.
140              
141             Default is 1024.
142              
143             =item * B
144              
145             Boolean.
146              
147             Automatically upload newly create map images to Flickr. Photos will be tagged with the following machine tags :
148              
149             =over 4
150              
151             =item * B
152              
153             Where I is the photo that has been added to the map image.
154              
155             =item * B
156              
157             =back
158              
159             Default is false.
160              
161             =item * B
162              
163             Boolean.
164              
165             Mark pinwin uploads to Flickr as viewable by anyone.
166              
167             Default is false.
168              
169             =item * B
170              
171             Boolean.
172              
173             Mark pinwin uploads to Flickr as viewable only by friends.
174              
175             Default is false.
176              
177             =item * B
178              
179             Boolean.
180              
181             Mark pinwin uploads to Flickr as viewable only by family.
182              
183             Default is false.
184              
185             =item * B
186              
187             String.
188              
189             The string label for the photo size to display, as defined by the flickr.photos.getSizes
190             API method :
191              
192             http://www.flickr.com/services/api/flickr.photos.getSizes.html
193              
194             Default is I
195              
196             =item * B
197              
198             Int.
199              
200             By default, the object will try to map the (Flickr) accuracy to the corresponding
201             zoom level of the Modest Maps provider you have chosen. If this option is defined
202             then it will be used as the zoom level regardless of what Flickr says.
203              
204             =item * B
205              
206             Int.
207              
208             Used by the I (and by extension I) object methods to
209             define the width of each slice taken from a poster map.
210              
211             Default is 1771
212              
213             =item * B
214              
215             Int.
216              
217             Used by the I (and by extension I) object methods to
218             define the height of each slice taken from a poster map.
219              
220             Default is 1239
221              
222             =item * B
223              
224             Int (or array reference of ints)
225              
226             Used by I related object methods, a list of photos to exclude from the list
227             returned by the Flickr API.
228              
229             =item * B
230              
231             String (or array reference of strings)
232              
233             Used by I related object methods, a list of tags that all photos must B have if
234             they are to be included in the final output.
235              
236             =item * B
237              
238             String (or array reference of strings)
239              
240             Used by I related object methods, a list of tags that all photos must have if
241             they are to be included in the final output.
242              
243             =head2 modestmaps
244              
245             =over 4
246              
247             =item * B
248              
249             The URL to a server running the ws-pinwin.py HTTP interface to the
250             ModestMaps tile-creation service.
251              
252             This requires Modest Maps 1.0 release or higher.
253              
254             =item * B
255              
256             A map provider and tile format for generating map images.
257              
258             As of this writing, current providers are :
259              
260             =over 4
261              
262             =item * B
263              
264             =item * B
265              
266             =item * B
267              
268             =item * B
269              
270             =item * B
271              
272             =item * B
273              
274             =item * B
275              
276             =item * B
277              
278             =item * B
279              
280             =back
281              
282             =item * B
283              
284             Used only when creating poster maps, the method parameter defines how the underlying
285             map is generated. Valid options are :
286              
287             =over 4
288              
289             =item * B
290              
291             Render map tiles at a suitable zoom level in order to fit the bounding
292             box (for all the images in a photoset) in an image with specific dimensions
293             (I and I).
294              
295             =item * B
296              
297             Render all the map tiles necessary to display the bounding box (for all the
298             images in a photoset) at a specific zoom level.
299              
300             =back
301              
302             Default is bbox.
303              
304             =item * B
305              
306             If true then extra white space will be added the underlying image in order to
307             fit any markers that may extend beyond the original dimensions of the map.
308              
309             Boolean.
310              
311             Default is true.
312              
313             =item * B
314              
315             Used only when creating poster maps, the adjust parameter tells the modest maps server
316             to extend bbox passed by I kilometers. This is mostly for esthetics so that there is
317             a little extra map love near pinwin located at the borders of a map.
318              
319             Boolean.
320              
321             Default is .25
322              
323             =item * B
324              
325             Tell the Modest Maps server to filter the rendered map image before applying an markers.
326             Valid options are :
327              
328             =over 4
329              
330             =item * B
331              
332             Apply the Atkinson dithering filter to the map image.
333              
334             This is brutally slow. Especially for poster maps. That's life.
335              
336             =back
337              
338             =item * B
339              
340             Int.
341              
342             The number of seconds the object's HTTP handler will wait when requesting data from the
343             Modest Maps server.
344              
345             Default is 300 seconds.
346              
347             =back
348              
349             =cut
350              
351             use Net::ModestMaps;
352             use Data::Dumper;
353             use FileHandle;
354             use GD;
355             use Imager;
356             use URI;
357              
358             # for clustermaps
359              
360             use List::Util qw(min max);
361             use Date::Calc qw (Today Add_Delta_Days Delta_Days);
362             use Geo::Geotude;
363             use Geo::Distance;
364             use LWP::Simple;
365             use POSIX qw (ceil floor);
366             use Image::Size qw(imgsize);
367              
368             =head1 PACKAGE METHODS
369              
370             =cut
371              
372             =head2 __PACKAGE__->new($cfg)
373              
374             Returns a I object.
375              
376             =cut
377              
378             # Defined in Net::Flickr::API
379              
380             =head1 PINWIN MAPS
381              
382             =cut
383              
384             =head2 $obj->mk_pinwin_map_for_photo($photo_id)
385              
386             Fetch a map using the Modest Maps ws-pinwin API for a geotagged Flickr photo
387             and place a "pinwin" style thumbnail of the photo over the map's marker.
388              
389             Returns an array of arrays (kind of pointless really, but at least consistent).
390              
391             The first element of the (second-level) array will be the path to the newly created map
392             image. If uploads are enabled the newly created Flickr photo ID will be
393             passed as the second element.
394              
395             =cut
396              
397             # Defined in Net::Flickr::Geo
398              
399             =head2 $obj->mk_pinwin_maps_for_photoset($photoset_id)
400              
401             For each geotagged photo in a set, fetch a map using the Modest Maps
402             ws-pinwin API for a geotagged Flickr photo and place a "pinwin" style
403             thumbnail of the photo over the map's marker.
404              
405             If uploads are enabled then each map for a given photo will be
406             added such that it appears before the photo it references.
407              
408             Returns an array of arrays.
409              
410             The first element of each (second-level) array reference will be the path to the newly
411             created map image. If uploads are enabled the newly created Flickr photo
412             ID will be passed as the second element.
413              
414             =cut
415              
416             # Defined in Net::Flickr::Geo
417              
418             =head1 POSTER MAPS
419              
420             =head2 $obj->mk_poster_map_for_photoset($set_id)
421              
422             For each geotagged photo in a set, plot the latitude and longitude and
423             create a bounding box for the collection. Then fetch a map for that box
424             using the Modest Maps ws-pinwin API for a geotagged Flickr photo and place
425             a "pinwin" style thumbnail for each photo in the set.
426              
427             Automatic uploads are not available for this method since the resultant
428             images will almost always be too big.
429              
430             Returns a hash reference containing the URL that was used to request the
431             map image, the path to the data that was sent back as well as all of the
432             Modest Maps specific headers sent back.
433              
434             =cut
435              
436             sub mk_poster_map_for_photoset {
437             my $self = shift;
438             my $set_id = shift;
439              
440             my $ph_size = $self->divine_option("pinwin.photo_size", "Medium");
441             my $provider = $self->divine_option("modestmaps.provider");
442             my $method = $self->divine_option("modestmaps.method", "bbox");
443             my $bleed = $self->divine_option("modestmaps.bleed", 1);
444             my $adjust = $self->divine_option("modestmaps.adjust", .25);
445             my $filter = $self->divine_option("modestmaps.filter", );
446              
447             my $upload = $self->divine_option("pinwin.upload", 0);
448              
449             #
450              
451             my $photos = $self->collect_photos_for_set($set_id);
452              
453             if (! $photos){
454             return undef;
455             }
456              
457             my $ne_lat = undef;
458             my $ne_lon = undef;
459              
460             my $sw_lat = undef;
461             my $sw_lon = undef;
462              
463             my %urls = ();
464             my @markers = ();
465             my @poly = ();
466              
467             foreach my $ph (@$photos){
468              
469             my $id = $ph->getAttribute("id");
470              
471             my $ph_url = $self->flickr_photo_url($ph);
472             $urls{$id} = $ph_url;
473              
474             my $sz = $self->api_call({'method' => 'flickr.photos.getSizes',
475             'args' => {'photo_id' => $id,}});
476            
477             my $sm = ($sz->findnodes("/rsp/sizes/size[\@label='$ph_size']"))[0];
478             my $w = $sm->getAttribute("width");
479             my $h = $sm->getAttribute("height");
480              
481             my $lat = $ph->getAttribute("latitude");
482             my $lon = $ph->getAttribute("longitude");
483              
484             push @poly, "$lat,$lon";
485              
486             $sw_lat = min($sw_lat, $lat);
487             $sw_lon = min($sw_lon, $lon);
488             $ne_lat = max($ne_lat, $lat);
489             $ne_lon = max($ne_lon, $lon);
490              
491             my %args = (
492             'uid' => $id,
493             'lat' => $lat,
494             'lon' => $lon,
495             'width' => $w,
496             'height' => $h,
497             );
498            
499             my $marker = Net::Flickr::Geo::ModestMaps::Marker->new(%args);
500             push @markers, $marker;
501             }
502              
503             my $bbox = "$sw_lat,$sw_lon,$ne_lat,$ne_lon";
504              
505             #
506             # fetch the actual map
507             #
508              
509             my $markers_prepped = Net::Flickr::Geo::ModestMaps::MarkerSet->prepare(\@markers);
510              
511             my %mm_args = (
512             'provider' => $provider,
513             'method' => $method,
514             'bleed' => $bleed,
515             'adjust' => $adjust,
516             'bbox' => $bbox,
517             'marker' => $markers_prepped,
518             );
519              
520             if ($method eq "extent"){
521             $mm_args{'width'} = $self->divine_option("pinwin.map_width", 1024);
522             $mm_args{'height'} = $self->divine_option("pinwin.map_height", 1024);
523             }
524              
525             else {
526             $mm_args{'zoom'} = $self->divine_option("modestmaps.zoom", 17);
527             }
528              
529             if ($filter){
530             $mm_args{'filter'} = $filter;
531             }
532              
533             if (my $convex = $self->divine_option("modestmaps.convex")){
534             $mm_args{'convex'} = $convex;
535             }
536              
537             $self->log()->info(Dumper(\%mm_args));
538              
539             my $map_data = $self->fetch_modestmap_image(\%mm_args);
540            
541             if (! $map_data){
542             return undef;
543             }
544              
545             #
546             # place the markers (please to refactor me...)
547             #
548              
549             my @images = ();
550              
551             foreach my $prop (%$map_data){
552              
553             if ($prop =~ /^marker-(.*)$/){
554              
555             my $id = $1;
556            
557             my $ph_url = $urls{$id};
558             my $ph_img = $self->mk_tempfile(".jpg");
559            
560             if (! $self->simple_get($ph_url, $ph_img)){
561             next;
562             }
563              
564             my @pw_details = split(",", $map_data->{$prop});
565             my $pw_x = $pw_details[2];
566             my $pw_y = $pw_details[3];
567             my $pw_w = $pw_details[4];
568             my $pw_h = $pw_details[5];
569              
570             push @images, [$ph_img, $pw_x, $pw_y, $pw_w, $pw_h];
571             }
572             }
573              
574             my $out = $self->place_marker_images($map_data->{'path'}, \@images);
575             $map_data->{'path'} = $out;
576              
577             return $map_data;
578             }
579              
580             =head2 $obj->upload_poster_map($poster_map)
581              
582             Take a file created by the I and chop it up
583             in "postcard-sized" pieces and upload each to Flickr.
584              
585             Returns an array of arrays.
586              
587             The first element of the (second-level) array will be the path to the newly created map
588             image. If uploads are enabled the newly created Flickr photo ID will be
589             passed as the second element.
590              
591             =cut
592              
593             sub upload_poster_map {
594             my $self = shift;
595             my $map = shift;
596              
597             my $slices = $self->crop_poster_map($map);
598             my @res = shift;
599              
600             foreach my $img (@$slices){
601              
602             my %args = ('photo' => $img);
603             my $id = $self->upload_image(\%args);
604            
605             push @res, [$img, $id];
606             unlink($img);
607             }
608              
609             return \@res;
610             }
611              
612             =head2 $obj->crop_poster_map($poster_map)
613              
614             Take a file created by the I and chop it up
615             in "postcard-sized" pieces.
616              
617             The height and width of each piece are defined by the I and
618             I config options.
619              
620             Any image whose cropping creates a file smaller than either dimension will
621             be padded with extra (white) space.
622              
623             Returns a list of files.
624              
625             =cut
626              
627             sub crop_poster_map {
628             my $self = shift;
629             my $map = shift;
630              
631             my $crop_width = $self->divine_option("pinwin.crop_width", 1771);
632             my $crop_height = $self->divine_option("pinwin.crop_width", 1239);
633              
634             my $offset_x = 0;
635             my $offset_y = 0;
636              
637             my @slices = ();
638              
639             my $im = Imager->new();
640             $im->read('file' => $map);
641              
642             my $map_h = $im->getheight();
643             my $map_w = $im->getwidth();
644              
645             while ($offset_x < $map_w) {
646              
647             while ($offset_y < $map_h) {
648              
649             my $x = $offset_x;
650             my $y = $offset_y;
651              
652             my $slice = $im->crop('left' => $x, 'top' => $y, 'width' => $crop_width, 'height' => $crop_height);
653              
654             my $h = $slice->getheight();
655             my $w = $slice->getwidth();
656            
657             if (($h < $crop_height) || ($w < $crop_width)){
658              
659             my $canvas = Imager->new('xsize' => $crop_width, 'ysize' => $crop_height);
660             $canvas->box('color' => 'white', 'xmin' => 0, 'ymin' => 0, 'xmax' => $crop_width, 'ymax' => $crop_height, 'filled' => 1);
661             $canvas->paste('img' => $slice, 'left' => 0, 'top' => 0);
662             push @slices, $canvas;
663             }
664              
665             else {
666             push @slices, $slice;
667             }
668              
669             $offset_y += $crop_height;
670             }
671              
672             $offset_x += $crop_width;
673             $offset_y = 0;
674             }
675              
676             my @files = map {
677             $self->write_jpeg($im);
678             } @slices;
679              
680             return \@files;
681             }
682              
683             =head1 CLUSTER MAPS
684              
685             =head2 $obj->mk_cluster_maps_for_photo($photo_id)
686              
687             Like poster maps, cluster maps plot many photos in multiple pinwins on a single
688             background map image. Unlike poster maps, where you explicitly list all the photos
689             to display (by specifying a photo set) cluster maps renders a single photo as its
690             principal focus with all the photos with in an (n) kilometer radius of the subject
691             photo.
692              
693             Multiple photos sharing the same latitude and longitude are also clustered together
694             and rendered in a single pinwin, whose size and shape is relative to the square
695             root of the number of total photos. This helps, in densely photographed areas, to
696             prevent cascading pinwins from rocketing off the map canvas trying to find a suitably
697             empty space to avoid overlapping other nearby pinwins.
698              
699             As of this writing, all the surrounding photos are rendered using their 75x75 pixel
700             square thumbnail though this will be a user-configurable option in future releases.
701             The principal photo size can still be set by assigning the I config
702             variable (the default is I).
703              
704             Cluster maps are not a general purpose interface to the Flickr I
705             method (yet) although there are some flags to limit search results to the principal
706             photo's owner or by one or more copyright licenses.
707              
708             All (except B, discussed below) the usual config options may be set for
709             cluster maps. In addition, you may also define the following options :
710              
711             =over 4
712              
713             =item * B
714              
715             Float.
716              
717             The number of kilometers from I<$photo_id>'s lat/lon in which to perform a
718             radial query for other geotagged photos.
719              
720             Default is I<1>
721              
722             =item * B
723              
724             Int.
725              
726             The number of days on either side of I<$photo_id>'s "date taken" value with which
727             to limit the scope of the query.
728              
729             Default is I<0>
730              
731             =item * B
732              
733             Boolean.
734              
735             Limit all queries to include only photos uploaded by I<$photo_id>'s owner.
736              
737             Default is I
738              
739             =item * B
740              
741             Boolean.
742              
743             Typically used when setting the I to ensure that nearby photos
744             uploaded by I<$photo_id>s owner are included. If true, this will cause the code
745             to execute the same search twice. The second query will remove any licensing
746             restrictions and enforce that only photos owned by I<$photo_id>'s owner be
747             returned. The two result sets will then be merged and sorted by distance from
748             the center point.
749              
750             (Ignored if the I is true.)
751              
752             Default is I
753              
754             =item * B
755              
756             String.
757              
758             A comma-separated list of Flickr license IDs to limit the list of photos returned
759             by the I API method.
760              
761             Default is none.
762              
763             =item * B
764              
765             String.
766              
767             Post search, ensure that all the photos have a minimum set of geo permissions.
768              
769             Valid options are : "public", "contact", "friend", "family", "friend or family"
770             and "all".
771              
772             Default is I
773              
774             =item * B
775              
776             Int.
777              
778             Although the clustering of photos sharing the same latitude and longitude helps
779             cut down on number of pinwins the Modest Maps needs to figure out how to layout
780             on the background map, there is still an upper limit after which it (Modest Maps)
781             will simply give up.
782              
783             The exact number is a little hard to say as it is usually a function of how closely
784             grouped any number of pinwins (clustered or not) are to each other. Anecdotally,
785             anything less than 100 is fine; less than 200 is a toss up; anything after that
786             usually wakes the baby.
787              
788             Default is I<100>
789              
790             =item * B
791              
792             Int.
793              
794             All of the clusters are grouped by their lat/lon position rounded off to three
795             decimal points. You can change this option to set the maximum number of photos
796             that can be contained in a single group.
797              
798             Default is half the value of the I parameter.
799              
800             =back
801              
802             If either the B or B is exceeded then another
803             search query is initiated, where the radial days offset from I<$photo_id>'s taken
804             date is reduced by 10%. If no offset value was set by the user then an initial
805             value of 365 is set (meaning that if there are still too many photos after the
806             second query it will be reset to 328 days and so on.)
807              
808             Finally, all cluster maps assume the Modest Maps B method. The bounding box
809             itself is calcluated using the photos further from the center and is adjusted (in
810             size) relative to distance between the south-west and north-east corners.
811              
812             If the distance is less that 1km, the bounding box will be expanded by .25km; If
813             the distance is less than 1.5km, the bounding box will be expanded by .1km; If the
814             bounding is less than 2km, the bounding box will be expanded by .1km.
815              
816             Returns a hash reference containing the URL that was used to request the
817             map image, the path to the data that was sent back as well as all of the
818             Modest Maps specific headers sent back.
819              
820             Attribution for the photos is returned in a hash refernce whose key is labeled
821             I and whose contents are a series of nested hashes mapping marker
822             IDs to owners and a list of photos for that marker. For example :
823              
824             $response = {
825             # lots of other stuff
826             'marker-2561168539' => '1455,4189,1427,2065,75,75',
827             'attribution' => {
828             '2366199422' => {
829             'foobar' => ['http://www.flickr.com/photos/foobar/999999'],
830             }
831             }
832             }
833              
834             =cut
835              
836             sub mk_cluster_map_for_photo {
837             my $self = shift;
838             my $photo_id = shift;
839              
840             my ($ph, $ph_marker, $queries) = $self->mk_cluster_map_for_photo_base($photo_id);
841             my ($markers, $bbox);
842              
843             # really ?
844              
845             if (scalar(@$queries) == 1){
846             ($markers, $bbox) = $self->search_for_cluster_map($queries->[0]);
847             }
848              
849             else {
850             ($markers, $bbox) = $self->blended_search_for_cluster_map($queries);
851             }
852              
853             #
854              
855             unshift @{$markers}, $ph_marker;
856             return $self->create_cluster_map($markers, $bbox);
857             }
858              
859             =head2 $obj->mk_historical_cluster_map_for_photo($photo_id)
860              
861             Historical cluster maps are similar to plain old cluster in nature, but not in
862             execution. Rather than doing a single query and showing whatever happens to be
863             closest to I<$photo_id> historical cluster maps rely on the code making two
864             calls to the photos.search each explicitly constrained by a date range.
865              
866             The first query will ask for photos within (n) days of when the photo was taken;
867             the second query will ask for photos within (n) days of today.
868              
869             The two result sets are then smushed together, sorted by distance to I<$photo_id>
870             and clustered in to groups. If the number of photos, or grouped photos, is too
871             high then each date range is reduced (the value of offset days is multiplied by 90%
872             and rounded down) and the process is repeated until everything fits.
873              
874             Or something breaks.
875              
876             Then we make a map!
877              
878             All the same rules and options that apply for plain old cluster maps are valid
879             for historical cluster maps.
880              
881             =cut
882              
883             sub mk_historical_cluster_map_for_photo {
884             my $self = shift;
885             my $photo_id = shift;
886              
887             my $offset = $self->divine_option("clustermap.offset", 0);
888             my $today = $self->today();
889             my ($before_now, $after_now) = $self->calculate_delta_days($today, $offset);
890              
891             my ($ph, $ph_marker, $queries) = $self->mk_cluster_map_for_photo_base($photo_id);
892              
893             # tmp array so we don't get stuck in an infinite
894             # loop always creating new queries to modify...
895              
896             my @new_queries = ();
897              
898             foreach my $query_then (@$queries){
899              
900             my %query_now = map {
901             $_ => $query_then->{$_};
902             } keys %{$query_then};
903            
904             $query_now{'min_taken_date'} = $before_now;
905             $query_now{'max_taken_date'} = $after_now;
906            
907             push @new_queries, \%query_now;
908             }
909              
910             map { push @$queries, $_ } @new_queries;
911              
912             my ($markers, $bbox) = $self->blended_search_for_cluster_map($queries);
913             unshift @{$markers}, $ph_marker;
914              
915             return $self->create_cluster_map($markers, $bbox);
916             }
917              
918             # shhh...
919              
920             sub mk_cluster_maps_for_photoset {
921             my $self = shift;
922             my $set_id = shift;
923              
924             my $upload = $self->divine_option('pinwin.upload', 0);
925              
926             #
927              
928             my $photos = $self->collect_photos_for_set($set_id);
929              
930             if (! $photos){
931             return undef;
932             }
933              
934             my @maps = ();
935             my @set = ();
936              
937             foreach my $ph (@$photos){
938              
939             my $uid = $ph->getAttribute("id");
940             my $map = $self->mk_cluster_map_for_photo($uid);
941              
942             if (! $map){
943             $self->log()->error("failed to generate cluster map for photo #$uid");
944             next;
945             }
946              
947             my @local_res = ($map->{'path'});
948              
949             if ($upload){
950             my $id = $self->upload_map($ph, $map->{'path'});
951              
952             push @local_res, $id;
953             push @set, $id;
954             push @set, $ph->getAttribute("id");
955             }
956              
957             push @maps, \@local_res;
958             }
959              
960             if (($upload) && (scalar(@set))) {
961             $self->api_call({'method' => 'flickr.photosets.editPhotos',
962             'args' => {'photoset_id' => $set_id,
963             'primary_photo_id' => $set[0],
964             'photo_ids' => join(",", @set)}});
965             }
966              
967             return \@maps;
968             }
969              
970             #
971             # not so public
972             #
973              
974             sub create_cluster_map {
975             my $self = shift;
976             my $markers = shift;
977             my $bbox = shift;
978              
979             my $mm_args = $self->prepare_modestmaps_args_for_cluster_map($markers, $bbox);
980             my $urls = $self->gather_urls_for_cluster_map($markers);
981              
982             my $map_data = $self->create_pinwin_map($mm_args, $urls);
983              
984             if (! $map_data){
985             return undef;
986             }
987              
988             #
989              
990             $map_data->{'attribution'} = $self->collect_attributions($markers);
991            
992             $self->log()->debug(Dumper($map_data));
993             return $map_data;
994             }
995              
996             sub create_pinwin_map {
997             my $self = shift;
998             my $mm_args = shift;
999             my $img_urls = shift;
1000              
1001             $self->log()->debug(Dumper($mm_args));
1002             $self->log()->debug(Dumper($img_urls));
1003              
1004             my $map_data = $self->fetch_modestmap_image($mm_args);
1005            
1006             if (! $map_data){
1007             return undef;
1008             }
1009            
1010             my $out = $self->place_map_images($map_data, $img_urls);
1011             $map_data->{'path'} = $out;
1012              
1013             return $map_data;
1014             }
1015              
1016             sub collect_attributions {
1017             my $self = shift;
1018             my $markers = shift;
1019              
1020             my %attribution = ();
1021              
1022             foreach my $mrk (@$markers){
1023            
1024             if (! exists($mrk->{'attribution'})){
1025             next;
1026             }
1027              
1028             my $uid = $mrk->{'uid'};
1029              
1030             if (ref($mrk->{'attribution'}) ne "ARRAY"){
1031             $attribution{$uid}->{$mrk->{'attribution'}->{'owner'}} = $mrk->{'attribution'}->{'url'};
1032             }
1033              
1034             else {
1035             map {
1036            
1037             my $owner = $_->{'owner'};
1038             my $url = $_->{'url'};
1039            
1040             $attribution{$uid}->{$owner} ||= [];
1041             push @{$attribution{$uid}->{$owner}}, $url;
1042            
1043             } @{$mrk->{'attribution'}};
1044             }
1045             }
1046              
1047             return \%attribution;
1048             }
1049              
1050             sub fetch_map_image {
1051             my $self = shift;
1052             my $ph = shift;
1053             my $thumb_data = shift;
1054              
1055             # please refactor me...
1056              
1057             my $lat = $self->get_geo_property($ph, "latitude");
1058             my $lon = $self->get_geo_property($ph, "longitude");
1059             my $acc = $self->get_geo_property($ph, "accuracy");
1060              
1061             if ((! $lat) || (! $lon)){
1062             return undef;
1063             }
1064              
1065             #
1066              
1067             my $zoom = $self->flickr_accuracy_to_zoom($acc);
1068             $self->log()->info("zoom to $zoom ($acc)");
1069              
1070             #
1071              
1072             my $out = $self->mk_tempfile(".png");
1073              
1074             my $provider = $self->divine_option("modestmaps.provider");
1075             my $bleed = $self->divine_option("modestmaps.bleed");
1076             my $filter = $self->divine_option("modestmaps.filter");
1077             $zoom = $self->divine_option("modestmaps.zoom", $zoom);
1078              
1079             #
1080              
1081             my %args = (
1082             'uid' => 'thumbnail',
1083             'lat' => $lat,
1084             'lon' => $lon,
1085             'width' => $thumb_data->{'width'},
1086             'height' => $thumb_data->{'height'},
1087             );
1088              
1089             my $marker = Net::Flickr::Geo::ModestMaps::Marker->new(%args);
1090              
1091             my $height = $self->divine_option("pinwin.map_height", 1024);
1092             my $width = $self->divine_option("pinwin.map_width", 1024);
1093              
1094             my %mm_args = (
1095             'provider' => $provider,
1096             'latitude' => $lat,
1097             'longitude' => $lon,
1098             'zoom' => $zoom,
1099             'method' => 'center',
1100             'height' => $height,
1101             'width' => $width,
1102             'bleed' => $bleed,
1103             'marker' => $marker,
1104             );
1105              
1106             if ($filter){
1107             $mm_args{'filter'} = $filter;
1108             }
1109              
1110             # $self->log()->info(Dumper(\%mm_args));
1111             return $self->fetch_modestmap_image(\%mm_args, $out);
1112             }
1113              
1114             sub flickr_accuracy_to_zoom {
1115             my $self = shift;
1116             my $acc = shift;
1117              
1118             my $provider = $self->divine_option("modestmaps.provider");
1119             $provider =~ /^([^_]+)_/;
1120             my $short = lc($1);
1121              
1122             if ($short eq 'yahoo'){
1123             return $acc;
1124             }
1125              
1126             else {
1127             return $acc + 1;
1128             }
1129              
1130             }
1131              
1132             sub fetch_modestmap_image {
1133             my $self = shift;
1134             my $args = shift;
1135             my $out = shift;
1136              
1137             $out ||= $self->mk_tempfile(".jpg");
1138              
1139             my $timeout = $self->divine_option("modestmaps.timeout", (5 * 60));
1140             my $remote = $self->divine_option("modestmaps.server");
1141              
1142             my $mm = Net::ModestMaps->new();
1143             $mm->host($remote);
1144             $mm->timeout($timeout);
1145             $mm->ensure_max_header_lines($args->{'marker'});
1146            
1147             my $data = $mm->draw($args, $out);
1148              
1149             if (my $err = $data->{'error'}){
1150             $self->log()->info("modestmaps error : $err->{'code'}, $err->{'message'}");
1151             return undef;
1152             }
1153              
1154             $self->log()->info("received modest map image and stored in $out");
1155             return $data;
1156             }
1157              
1158             sub modify_map {
1159             my $self = shift;
1160             my $ph = shift;
1161             my $map_data = shift;
1162             my $thumb_data = shift;
1163              
1164             my @pw_details = split(",", $map_data->{'marker-thumbnail'});
1165             my $pw_x = $pw_details[2];
1166             my $pw_y = $pw_details[3];
1167             my $pw_w = $pw_details[4];
1168             my $pw_h = $pw_details[5];
1169            
1170             my @images = ([$thumb_data->{path}, $pw_x, $pw_y, $pw_w, $pw_h]);
1171              
1172             return $self->place_marker_images($map_data->{'path'}, \@images);
1173             }
1174              
1175             sub place_marker_images {
1176             my $self = shift;
1177             my $map_img = shift;
1178             my $markers = shift;
1179              
1180             # use GD instead of Imager because the latter has
1181             # a habit of rendeing the actual thumbnails all wrong...
1182              
1183             # ensure the truecolor luv to prevent nasty dithering
1184              
1185             my $truecolor = 1;
1186            
1187             my $im = GD::Image->newFromPng($map_img, $truecolor);
1188              
1189             foreach my $data (@$markers){
1190             my ($mrk_img, $x, $y, $w, $h) = @$data;
1191             my $ph = GD::Image->newFromJpeg($mrk_img, $truecolor);
1192            
1193             eval {
1194             $im->copy($ph, $x, $y, 0, 0, $w, $h);
1195             };
1196              
1197             if ($@){
1198             $self->log()->error("picture made GD cry, skipping. $@");
1199             }
1200              
1201             unlink($mrk_img);
1202             }
1203              
1204             unlink($map_img);
1205              
1206             return $self->write_jpeg($im);
1207             }
1208              
1209             sub place_map_images {
1210             my $self = shift;
1211             my $map_data = shift;
1212             my $urls = shift;
1213              
1214             my @images = ();
1215              
1216             foreach my $prop (%$map_data){
1217              
1218             if ($prop =~ /^marker-(.*)$/){
1219              
1220             my $id = $1;
1221            
1222             my $ph_url = $urls->{$id};
1223             my $ph_img = $self->mk_tempfile(".jpg");
1224            
1225             $self->log()->info("fetch $ph_url");
1226              
1227             if (! $self->simple_get($ph_url, $ph_img)){
1228             $self->log()->error("failed to retrieve $ph_url, $!");
1229             next;
1230             }
1231              
1232             my @pw_details = split(",", $map_data->{$prop});
1233             my $pw_x = $pw_details[2];
1234             my $pw_y = $pw_details[3];
1235             my $pw_w = $pw_details[4];
1236             my $pw_h = $pw_details[5];
1237              
1238             push @images, [$ph_img, $pw_x, $pw_y, $pw_w, $pw_h];
1239             }
1240             }
1241              
1242             return $self->place_marker_images($map_data->{'path'}, \@images);
1243             }
1244              
1245             #
1246             # search
1247             #
1248              
1249             sub collect_blended_search {
1250             my $self = shift;
1251             my $queries = shift;
1252              
1253             my $perms = $self->divine_option("clustermap.geo_perms", "all");
1254              
1255             my %unsorted = ();
1256             my @possible = ();
1257              
1258             my %seen = ();
1259              
1260             foreach my $q (@$queries){
1261              
1262             my %local_q = ();
1263              
1264             foreach my $k (keys %{$q}){
1265             if ($k =~ /^__/){
1266             next;
1267             }
1268              
1269             $local_q{$k} = $q->{$k};
1270             }
1271              
1272             my $search = $self->api_call({'method' => 'flickr.photos.search', 'args' => \%local_q});
1273              
1274             foreach my $ph ($search->findnodes("/rsp/photos/photo")){
1275              
1276             my $uid = $ph->getAttribute("id");
1277              
1278             if (exists($seen{$uid})){
1279             next;
1280             }
1281              
1282             $seen{$uid} ++;
1283              
1284             if (exists($q->{'__exclude'})){
1285             if (grep /$uid/, @{$q->{'__exclude'}}){
1286             $self->log()->info("exclude photo #$uid from blended search");
1287             next;
1288             }
1289             }
1290              
1291             if (! $self->ensure_geo_perms($uid, $perms)){
1292             next;
1293             }
1294              
1295             my $geo = Geo::Distance->new();
1296             my $dist = $geo->distance("kilometer", $q->{'lon'}, $q->{'lat'}, $ph->getAttribute("longitude"), $ph->getAttribute("latitude"));
1297              
1298             $unsorted{$dist} ||= [];
1299             push @{$unsorted{$dist}}, $ph;
1300             }
1301             }
1302              
1303             foreach my $dist (sort {$a <=> $b} keys %unsorted){
1304             map { push @possible, $_ } @{$unsorted{$dist}};
1305             }
1306              
1307             return \@possible;
1308             }
1309              
1310             #
1311             # marker methods
1312             #
1313              
1314             sub gather_urls_for_cluster_map {
1315             my $self = shift;
1316             my $markers = shift;
1317              
1318             my %urls = map {
1319             $_->{'uid'} => $_->{'url'};
1320             } @$markers;
1321              
1322             return \%urls;
1323             }
1324              
1325             #
1326             # cluster methods (photo)
1327             #
1328              
1329             sub mk_cluster_map_for_photo_base {
1330             my $self = shift;
1331             my $photo_id = shift;
1332              
1333             my $ph_size = $self->divine_option("pinwin.photo_size", "Medium");
1334             my $r = $self->divine_option("clustermap.radius", 1);
1335             my $offset = $self->divine_option("clustermap.offset", 0);
1336             my $own = $self->divine_option("clustermap.only_photo_owner", 1);
1337             my $force_own = $self->divine_option("clustermap.force_photo_owner", 0);
1338             my $license = $self->divine_option("clustermap.photo_license", "*");
1339              
1340             #
1341              
1342             my $ph = $self->api_call({'method' => 'flickr.photos.getInfo', 'args' => {'photo_id' => $photo_id}});
1343             $ph = ($ph->findnodes("/rsp/photo"))[0];
1344              
1345             my $owner = $ph->findvalue("owner/\@nsid");
1346              
1347             my $lat = $self->get_geo_property($ph, "latitude");
1348             my $lon = $self->get_geo_property($ph, "longitude");
1349              
1350             my $sizes = $self->api_call({'method' => 'flickr.photos.getSizes', 'args' => {'photo_id' => $photo_id}});
1351            
1352             my $h = $sizes->findvalue("/rsp/sizes/size[\@label='Medium']/\@height");
1353             my $w = $sizes->findvalue("/rsp/sizes/size[\@label='Medium']/\@width");
1354            
1355             my $url = sprintf("http://farm%d.static.flickr.com/%d/%s_%s.jpg",
1356             $ph->getAttribute("farm"),
1357             $ph->getAttribute("server"),
1358             $ph->getAttribute("id"),
1359             $ph->getAttribute("secret"));
1360              
1361             my %args = (
1362             'uid' => $photo_id,
1363             'lat' => $lat,
1364             'lon' => $lon,
1365             'width' => $w,
1366             'height' => $h,
1367             'url' => $url,
1368             );
1369            
1370             if ($license){
1371             my $username = $ph->findvalue("owner/\@username");
1372             my $ph_url = $ph->findvalue("urls/url[\@type='photopage']");
1373             $args{'attribution'} = {'owner' => $username, 'url' => $ph_url};
1374             }
1375              
1376             my $ph_marker = Net::Flickr::Geo::ModestMaps::Marker->new(%args);
1377              
1378             #
1379             # Basic search criteria
1380             #
1381              
1382             my %query = (
1383             'lat' => $lat,
1384             'lon' => $lon,
1385             'radius' => $r,
1386             'extras' => 'geo,date_taken',
1387             '__exclude' => [$ph->getAttribute('id')],
1388             );
1389              
1390             if ($license ne "*"){
1391             $query{'license'} = $license;
1392             $query{'extras'} .= ",owner_name";
1393             }
1394              
1395             if ($own){
1396             $query{'user_id'} = $owner;
1397             }
1398              
1399             if ($offset){
1400             my $dt = $ph->findvalue("dates/\@taken");
1401             my ($before, $after) = $self->calculate_delta_days($dt, $offset);
1402              
1403             $query{'min_taken_date'} = $before;
1404             $query{'max_taken_date'} = $after;
1405             }
1406              
1407             #
1408              
1409             my @queries = (\%query);
1410              
1411             #
1412              
1413             if ((! $own) && ($force_own) && (exists($query{'license'}))){
1414              
1415             $self->log()->info("forcing photo owner photos, requires a blended search");
1416              
1417             my %query_me = map {
1418             $_ => $query{$_};
1419             } keys %query;
1420              
1421             delete($query_me{'license'});
1422             $query_me{'user_id'} = $ph->findvalue("owner/\@nsid");
1423              
1424             push @queries, \%query_me;
1425             }
1426              
1427             #
1428              
1429             return ($ph, $ph_marker, \@queries);
1430             }
1431              
1432             #
1433             # cluster methods (markers)
1434             #
1435              
1436             sub markers_for_clusters {
1437             my $self = shift;
1438             my $clusters = shift;
1439              
1440             my @markers = ();
1441              
1442             foreach my $key (keys %{$clusters}){
1443              
1444             if (scalar(@{$clusters->{$key}}) == 1){
1445             push @markers, $clusters->{$key}->[0];
1446             next;
1447             }
1448              
1449             my @images = ();
1450             my @attribution = ();
1451              
1452             my ($uid, $lat, $lon);
1453              
1454             foreach my $mrk (@{$clusters->{$key}}){
1455              
1456             if (! exists($mrk->{'url'})){
1457             next;
1458             }
1459              
1460             $uid = $mrk->{'uid'};
1461             $lat = $mrk->{'lat'};
1462             $lon = $mrk->{'lon'};
1463              
1464             push @images, $mrk->{'url'};
1465              
1466             if (exists($mrk->{'attribution'})){
1467             push @attribution, $mrk->{'attribution'};
1468             }
1469             }
1470              
1471             # reassign $w,$h
1472              
1473             my $stacked = $self->stack_images(\@images);
1474             my ($w, $h) = imgsize($stacked);
1475              
1476             my $url = "file://" . $stacked;
1477              
1478             my %args = ('uid' => $uid,
1479             'lat' => $lat,
1480             'lon' => $lon,
1481             'width' => $w,
1482             'height' => $h,
1483             'url' => $url,
1484             'attribution' => \@attribution,
1485             );
1486              
1487             my $marker = Net::Flickr::Geo::ModestMaps::Marker->new(%args);
1488             push @markers, $marker;
1489             }
1490              
1491             return \@markers;
1492             }
1493              
1494             #
1495             # cluster methods (search)
1496             #
1497              
1498             sub search_for_cluster_map {
1499             my $self = shift;
1500             my $query = shift;
1501             return $self->blended_search_for_cluster_map([$query]);
1502             }
1503              
1504             sub blended_search_for_cluster_map {
1505             my $self = shift;
1506             my $queries = shift;
1507              
1508             my $max_photos = $self->divine_option("clustermap.max_photos", 100);
1509             my $max_grouped = $self->divine_option("clustermap.max_photos_per_group", int($max_photos / 2));
1510             my $offset = $self->divine_option("clustermap.offset", 0);
1511              
1512             my $photos = undef;
1513             my $clusters = undef;
1514             my $groups = undef;
1515             my $bbox = undef;
1516              
1517             my $ok = 0;
1518              
1519             my @pts = ();
1520              
1521             foreach my $q (@$queries){
1522             push @pts, [$q->{'lat'}, $q->{'lon'}];
1523             }
1524              
1525             while (! $ok){
1526              
1527             $self->log()->debug("new blended search");
1528             $photos = $self->collect_blended_search($queries);
1529              
1530             my $cnt_photos = scalar(@$photos);
1531             my $cnt_groups = 0;
1532             my $cnt_clusters = 0;
1533             my $cnt_grouped = 0;
1534              
1535             my $local_ok = ($cnt_photos <= $max_photos) ? 1 : 0;
1536              
1537             $self->log()->info("search returns $cnt_photos photos (max : $max_photos)");
1538              
1539             if ($local_ok){
1540             ($clusters, $groups, $bbox) = $self->cluster_blended_search($photos, \@pts);
1541              
1542             $cnt_groups = scalar(keys %$groups);
1543             $cnt_clusters = scalar(keys %$clusters);
1544            
1545             $cnt_grouped = map { max($cnt_grouped, $_) } values %$groups;
1546              
1547             $self->log()->info("search returned $cnt_clusters clustered photos, across $cnt_groups groups (max photos/group : $cnt_grouped)");
1548             $local_ok = ($cnt_grouped <= $max_grouped) ? 1 : 0;
1549             }
1550              
1551             if ($local_ok){
1552             $ok = 1;
1553             last;
1554             }
1555              
1556             # reset
1557              
1558             $self->log()->info("too many photos adjusting offset and radius so as not to make modestmaps cry");
1559              
1560             $offset = ($offset) ? floor($offset * .9) : 365;
1561              
1562             if ($offset <= 0){
1563             $self->log()->error("offset equals zero, eyes turning black");
1564             return undef;
1565             }
1566              
1567             foreach my $q (@{$queries}){
1568              
1569             if ((! $q->{'min_taken_date'}) || (! $q->{'max_taken_date'})){
1570              
1571             my $today = $self->today();
1572             my ($min, $max) = $self->calculate_delta_days($today, $offset);
1573              
1574             $q->{'min_taken_date'} = $min;
1575             $q->{'max_taken_date'} = $max;
1576             }
1577              
1578             else {
1579              
1580             $q->{'min_taken_date'} =~ /(\d{4})-(\d{2})-(\d{2})/;
1581             my ($y1, $m1, $d1) = ($1, $2, $3);
1582              
1583             $q->{'max_taken_date'} =~ /(\d{4})-(\d{2})-(\d{2})/;
1584             my ($y2, $m2, $d2) = ($1, $2, $3);
1585            
1586             my $delta = Delta_Days($y1, $m1, $d1, $y2, $m2, $d2);
1587             my @new = Add_Delta_Days($y1, $m1, $d1, int($delta / 2));
1588            
1589             my $start = sprintf("%04d-%02d-%02d", @new);
1590             $self->log()->info("reset start date to $start with an offset of $offset days");
1591            
1592             my ($min, $max) = $self->calculate_delta_days($start, $offset);
1593            
1594             $self->log()->info("reset min taken date from $q->{'min_taken_date'} to $min");
1595             $self->log()->info("reset min taken date from $q->{'max_taken_date'} to $max");
1596            
1597             $q->{'min_taken_date'} = $min;
1598             $q->{'max_taken_date'} = $max;
1599             }
1600              
1601             #
1602              
1603             $q->{'per_page'} ||= $self->divine_option("clustermap.max_photos", 100);
1604             $q->{'per_page'} = ceil($q->{'per_page'} * .9);
1605             }
1606             }
1607              
1608             #
1609              
1610             my $markers = $self->markers_for_clusters($clusters);
1611              
1612             return ($markers, $bbox);
1613             }
1614              
1615             sub cluster_blended_search {
1616             my $self = shift;
1617             my $photos = shift;
1618             my $pts = shift;
1619              
1620             my %clusters = ();
1621             my %groups = ();
1622             my %bbox = ();
1623              
1624             if (defined($pts)){
1625             foreach my $c (@$pts){
1626             $bbox{'sw_lat'} = (exists($bbox{'sw_lat'})) ? min($bbox{'sw_lat'}, $c->[0]) : $c->[0];
1627             $bbox{'sw_lon'} = (exists($bbox{'sw_lon'})) ? min($bbox{'sw_lon'}, $c->[1]) : $c->[1];
1628             $bbox{'ne_lat'} = (exists($bbox{'ne_lat'})) ? max($bbox{'ne_lat'}, $c->[0]) : $c->[0];
1629             $bbox{'ne_lon'} = (exists($bbox{'ne_lon'})) ? max($bbox{'ne_lon'}, $c->[1]) : $c->[1];
1630             }
1631             }
1632            
1633             foreach my $ph (@$photos){
1634            
1635             my $uid = $ph->getAttribute("id");
1636              
1637             my $lat = $self->get_geo_property($ph, "latitude");
1638             my $lon = $self->get_geo_property($ph, "longitude");
1639             my $cluster_key = $self->geotude($lat, $lon);
1640            
1641             # to do : allow for other sizes and center crop...
1642              
1643             my $url = sprintf("http://farm%d.static.flickr.com/%d/%s_%s_s.jpg",
1644             $ph->getAttribute("farm"),
1645             $ph->getAttribute("server"),
1646             $ph->getAttribute("id"),
1647             $ph->getAttribute("secret"));
1648              
1649             my %args = (
1650             'uid' => $uid,
1651             'lat' => $lat,
1652             'lon' => $lon,
1653             'width' => 75,
1654             'height' => 75,
1655             'url' => $url,
1656             );
1657              
1658             # attribution
1659              
1660             if (my $owner = $ph->getAttribute("ownername")){
1661              
1662             my $page = sprintf("http://www.flickr.com/photos/%s/%s",
1663             $ph->getAttribute("owner"),
1664             $uid);
1665              
1666             $args{'attribution'} = {'owner' => $owner, 'url' => $page};
1667             }
1668              
1669             my $marker = Net::Flickr::Geo::ModestMaps::Marker->new(%args);
1670            
1671             $clusters{$cluster_key} ||= [];
1672             push @{$clusters{$cluster_key}}, $marker;
1673              
1674             $bbox{'sw_lat'} = (exists($bbox{'sw_lat'})) ? min($bbox{'sw_lat'}, $lat) : $lat;
1675             $bbox{'sw_lon'} = (exists($bbox{'sw_lon'})) ? min($bbox{'sw_lon'}, $lon) : $lon;
1676             $bbox{'ne_lat'} = (exists($bbox{'ne_lat'})) ? max($bbox{'ne_lat'}, $lat) : $lat;
1677             $bbox{'ne_lon'} = (exists($bbox{'ne_lon'})) ? max($bbox{'ne_lon'}, $lon) : $lon;
1678            
1679             # to do : check to see how closely together
1680             # stuff is clustered; weight counts below accordingly
1681            
1682             my $rnd_lat = sprintf("%.2f", $lat);
1683             my $rnd_lon = sprintf("%.2f", $lon);
1684             my $group_key = $self->geotude($rnd_lat, $rnd_lon);
1685            
1686             $groups{$group_key} ++;
1687             }
1688              
1689             return \%clusters, \%groups, \%bbox;
1690             }
1691              
1692             #
1693             # cluster methods (other)
1694             #
1695              
1696             sub prepare_modestmaps_args_for_cluster_map {
1697             my $self = shift;
1698             my $markers = shift;
1699             my $bbox = shift;
1700              
1701             my $provider = $self->divine_option("modestmaps.provider");
1702             my $bleed = $self->divine_option("modestmaps.bleed", 1);
1703             my $adjust = $self->divine_option("modestmaps.adjust", .25);
1704             my $filter = $self->divine_option("modestmaps.filter", );
1705             my $zoom = $self->divine_option("modestmaps.zoom", 17);
1706              
1707             my $markers_prepped = Net::Flickr::Geo::ModestMaps::MarkerSet->prepare($markers);
1708              
1709             my %mm_args = (
1710             'provider' => $provider,
1711             'method' => 'bbox',
1712             'bleed' => $bleed,
1713             'adjust' => $adjust,
1714             'marker' => $markers_prepped,
1715             'zoom' => $zoom,
1716             'bbox' => "$bbox->{'sw_lat'},$bbox->{'sw_lon'},$bbox->{'ne_lat'},$bbox->{'ne_lon'}",
1717             );
1718              
1719             my $dist_avg = $self->calculate_average_distance($bbox);
1720            
1721             my $readjust = 0;
1722            
1723             if ($dist_avg < 1){
1724             $readjust = .25;
1725             }
1726            
1727             elsif ($dist_avg < 1.5){
1728             $readjust = .15
1729             }
1730            
1731             elsif ($dist_avg < 2){
1732             $readjust = .1;
1733             }
1734            
1735             else { }
1736            
1737             if (($readjust) && ($readjust > $mm_args{'adjust'})){
1738             $self->log()->info("autosetting modestmaps adjust parameter to $readjust");
1739             $mm_args{'adjust'} = $readjust;
1740             }
1741              
1742             if ($filter){
1743             $mm_args{'filter'} = $filter;
1744             }
1745              
1746             return \%mm_args;
1747             }
1748              
1749             #
1750             # geo
1751             #
1752              
1753             sub geotude {
1754             my $self = shift;
1755             my $lat = shift;
1756             my $lon = shift;
1757              
1758             my $geo = Geo::Geotude->new('latitude' => $lat, 'longitude' => $lon);
1759             return $geo->geotude();
1760             }
1761              
1762             sub calculate_average_distance {
1763             my $self = shift;
1764             my $bbox = shift;
1765              
1766             my $geo = Geo::Distance->new();
1767            
1768             my $dist_x = $geo->distance("kilometer", $bbox->{'sw_lon'}, $bbox->{'sw_lat'}, $bbox->{'sw_lon'}, $bbox->{'ne_lat'});
1769             my $dist_y = $geo->distance("kilometer", $bbox->{'sw_lon'}, $bbox->{'sw_lat'}, $bbox->{'sw_lon'}, $bbox->{'ne_lat'});
1770            
1771             my $dist_avg = ($dist_x + $dist_y) / 2;
1772            
1773             $self->log()->info("distance between sw and ne corners is $dist_x km and $dist_y km");
1774             $self->log()->info("average distance is $dist_avg km");
1775              
1776             return $dist_avg;
1777             }
1778              
1779             #
1780             # images
1781             #
1782              
1783             sub stack_images {
1784             my $self = shift;
1785             my $images = shift;
1786              
1787             my $count = scalar(@$images);
1788             my $per_row = ceil(sqrt($count));
1789             my $rows = ceil($count/$per_row);
1790              
1791             my $other_rows = $rows - 1;
1792             my $last_row = $count - ($other_rows * $per_row);
1793              
1794             if ($last_row == $other_rows){
1795             $per_row += 1;
1796             $rows -= 1;
1797             }
1798              
1799             $self->log()->info("stacking $count images $per_row per row for a total of $rows rows");
1800              
1801             my $spacer_px = 10;
1802             my $spacers_w = $per_row - 1;
1803             my $spacers_h = $rows - 1;
1804              
1805             my $cnv_w = ($per_row * 75) + ($spacers_w * $spacer_px);
1806             my $cnv_h = ($rows * 75) + ($spacers_h * $spacer_px);
1807              
1808             $self->log()->debug("stacking canvas is $cnv_w x $cnv_h pixels");
1809              
1810             my $truecolor = 1;
1811             GD::Image->trueColor($truecolor);
1812              
1813             my $im = new GD::Image($cnv_w, $cnv_h);
1814             my $wh = $im->colorAllocate(255, 255, 255);
1815              
1816             $im->filledRectangle(0, 0, $cnv_w, $cnv_h, $wh);
1817              
1818             my $across = 1;
1819             my $down = 1;
1820              
1821             foreach my $url (@$images){
1822              
1823             my $tmp = $self->mk_tempfile(".jpg");
1824              
1825             if (! getstore($url, $tmp)){
1826             $self->log()->error("failed to retrieve $url for stacking, $!");
1827             next;
1828             }
1829              
1830             my $ph = GD::Image->newFromJpeg($tmp, $truecolor);
1831              
1832             if (! $ph){
1833             $self->log()->error("failed to create image from $tmp, $!");
1834             next;
1835             }
1836              
1837             my $copy_x = ($spacer_px * ($across - 1)) + (75 * ($across - 1));
1838             my $copy_y = ($spacer_px * ($down - 1)) + (75 * ($down - 1));
1839              
1840             $self->log()->debug("copy image at $copy_x ($across accross) and $copy_y ($down down)");
1841              
1842             eval {
1843             $im->copy($ph, $copy_x, $copy_y, 0, 0, 75, 75);
1844             };
1845              
1846             if ($@){
1847             $self->log()->error("picture made GD cry, skipping. $@");
1848             }
1849              
1850             unlink($tmp);
1851              
1852             if ($across == $per_row){
1853             $across = 1;
1854             $down += 1;
1855             }
1856              
1857             else {
1858             $across += 1
1859             }
1860             }
1861              
1862             return $self->write_jpeg($im);
1863             }
1864              
1865             sub write_jpeg {
1866             my $self = shift;
1867             my $im = shift;
1868             my $out = shift;
1869              
1870             if (! defined($out)){
1871             $out = $self->mk_tempfile(".jpg");
1872             }
1873              
1874             my $fh = FileHandle->new(">$out");
1875              
1876             binmode($fh);
1877             $fh->print($im->jpeg(100));
1878             $fh->close();
1879              
1880             return $out;
1881             }
1882              
1883             #
1884             # datetime
1885             #
1886              
1887             sub calculate_delta_days {
1888             my $self = shift;
1889             my $dt = shift;
1890             my $offset = shift;
1891            
1892             $dt =~ /^(\d{4})-(\d{2})-(\d{2})/;
1893            
1894             my $yyyy = $1;
1895             my $mm = $2;
1896             my $dd = $3;
1897            
1898             my $before = sprintf("%04d-%02d-%02d 00:00:00", Add_Delta_Days($yyyy, $mm, $dd, -$offset));
1899             my $after = sprintf("%04d-%02d-%02d 23:59:59", Add_Delta_Days($yyyy, $mm, $dd, $offset));
1900              
1901             return ($before, $after);
1902             }
1903              
1904             sub today {
1905             my $self = shift;
1906             return sprintf("%04d-%02d-%02d", Today());
1907             }
1908              
1909             #
1910             # hey ! look over there !!
1911             #
1912              
1913             package Net::Flickr::Geo::ModestMaps::MarkerSet;
1914              
1915             sub prepare {
1916             my $pkg = shift;
1917             my $markers = shift;
1918              
1919             if (ref($markers) ne "ARRAY"){
1920             return "$markers";
1921             }
1922              
1923             my @prep = map { "$_" } @$markers;
1924             return \@prep;
1925             }
1926              
1927             package Net::Flickr::Geo::ModestMaps::Marker;
1928              
1929             use overload q("") => sub {
1930             my $self = shift;
1931              
1932             my @parts = map {
1933             $self->{$_}
1934             } qw(uid lat lon width height);
1935              
1936             return join(",", @parts);
1937             };
1938              
1939             sub new {
1940             my $pkg = shift;
1941             my %self = @_;
1942             return bless \%self, $pkg;
1943             }
1944              
1945             =head1 VERSION
1946              
1947             0.72
1948              
1949             =head1 DATE
1950              
1951             $Date: 2008/08/03 17:08:39 $
1952              
1953             =head1 AUTHOR
1954              
1955             Aaron Straup Cope Eascope@cpan.orgE
1956              
1957             =head1 EXAMPLES
1958              
1959             L
1960              
1961             =head1 REQUIREMENTS
1962              
1963             Modest Maps 1.0 or higher.
1964              
1965             L
1966              
1967             =head1 NOTES
1968              
1969             All uploads to Flickr are marked with a content-type of "other".
1970              
1971             =head1 SEE ALSO
1972              
1973             L
1974              
1975             L
1976              
1977             L
1978              
1979             L
1980              
1981             L
1982              
1983             =head1 BUGS
1984              
1985             Sure, why not.
1986              
1987             Please report all bugs via L
1988              
1989             =head1 LICENSE
1990              
1991             Copyright (c) 2007-2008 Aaron Straup Cope. All Rights Reserved.
1992              
1993             This is free software. You may redistribute it and/or
1994             modify it under the same terms as Perl itself.
1995              
1996             =cut
1997              
1998             return 1;