package makeGeoJSON;

# Filename:	makeGeoJSON.pm
# Author:	David Ljung Madison <DaveSource.com>
# See License:	http://MarginalHacks.com/License/
# Description:	Make GeoJSON files for Google Maps
#               Drop-in replacement for makeKML - same API, GeoJSON output.
#
# Geocoding setup is identical to makeKML.pm.  See that module for full docs.

use strict;
use IO::File;
use vars qw($API @ISA @EXPORT @EXPORT_OK $VERSION $LIBRARY);
use Carp;

$API = 3;
sub useAPI { my ($class,$val) = @_; $API = $val; }

use Exporter ();
@ISA = qw(Exporter);
@EXPORT_OK = qw(add write);

$VERSION = '1.00';
$LIBRARY = __PACKAGE__;

my $KEY;
my $KEYFILE;
my $CACHEFILE = "latlong.cache";
my $OFFSETDUPLICATES = 0.001;
my $LATLONGRE = '(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)';

# Same style names as makeKML, mapped to icon URLs.
# Note: makeKML has a bug ({ vs () ) that makes its %STYLES hash broken;
# this version is correct.  bluepaddle is added here (was missing in makeKML).
my $MICON = 'https://maps.gstatic.com/mapfiles/ms2/micons';
my %STYLES = (
	'redpush'      => "$MICON/red-dot.png",
	'bluepush'     => "$MICON/blue-dot.png",
	'whitepush'    => "$MICON/orange-dot.png",
	'yellowpush'   => "$MICON/yellow-dot.png",
	'greenpush'    => "$MICON/green-dot.png",
	'ltbluepush'   => "$MICON/ltblue-dot.png",
	'purplepush'   => "$MICON/purple-dot.png",
	'bluepaddle'   => "$MICON/ltblue-dot.png",
	'greenpaddle'  => "$MICON/green-dot.png",
	'ltbluepaddle' => "$MICON/ltblue-dot.png",
	'pinkpaddle'   => "$MICON/pink-dot.png",
	'purplepaddle' => "$MICON/purple-dot.png",
	'redpaddle'    => "$MICON/red-dot.png",
	'whitepaddle'  => "$MICON/orange-dot.png",
	'yellowpaddle' => "$MICON/yellow-dot.png",
);

##################################################
# LatLong code (identical to makeKML.pm)
##################################################
sub Key {
	my ($class,$key) = @_;
	$KEY = $key if defined $key;
	return $KEY;
}

sub KeyFile {
	my ($class,$file) = @_;
	return $KEYFILE = $file if defined $file;

	my $provider = $API =~ /opencage/i ? "opencagedata" : "google";
	my @try = $KEYFILE ? ($KEYFILE) :
		( "%s_api_key",
			".%s_api_key",
			"$ENV{HOME}/.%s/maps-api-key",
			"$ENV{HOME}/.%s_api_key" );

	foreach my $try ( @try ) {
		$try =~ s/%s/$provider/g;
		return $try if -f $try;
	}
	return $try[0];
}

sub googleKey     { return Key(@_); }
sub googleKeyFile { return KeyFile(@_); }

sub latlongCache {
	my ($class,$file) = @_;
	$CACHEFILE = $file if defined $file;
	return $CACHEFILE;
}

sub offsetDuplicatePlacemarks {
	my ($class,$amount) = @_;
	$OFFSETDUPLICATES = $amount if defined $amount;
	return $OFFSETDUPLICATES;
}

sub debug {
	return unless $MAIN::DEBUG;
	foreach my $msg (@_) { print STDERR "[$LIBRARY] $msg\n"; }
}

##################################################
# Lat/long cache
##################################################
my %CACHE;
sub readLatLongCache {
	my ($self) = @_;
	my $cache = $self->{cache} || $CACHEFILE;
	return unless $cache && -f $cache;
	croak("[$LIBRARY] Can't open latlong cache [$cache]")
		unless open(CACHE,"<$cache");
	debug("Reading cache [$cache]");
	while (<CACHE>) { $CACHE{$1}=$2 if /^(.+)\t(.+)$/ }
	close CACHE;
}

sub writeLatLongCache {
	my ($self) = @_;
	my $cache = $self->{cache} || $CACHEFILE;
	return unless $cache;
	my $write = "$cache.tmp";
	croak("[$LIBRARY] Can't write cache [$write]")
		unless open(CACHE,">$write");
	debug("Writing cache [$write]");
	foreach my $key ( keys %CACHE ) { print CACHE "$key\t$CACHE{$key}\n" }
	close CACHE;
	rename($write,$cache);
}

##################################################
# Geocoding
##################################################
sub escape {
	my($toencode) = @_;
	$toencode=~s/([^a-zA-Z0-9_\-. ])/uc sprintf("%%%02x",ord($1))/eg;
	$toencode =~ tr/ /+/;
	return $toencode;
}

sub getCoordsV3 {
	my ($self,$addr) = @_;
	my $eaddr = escape($addr);
	return $CACHE{$eaddr} if $CACHE{$eaddr};
	my $url = "https://maps.googleapis.com/maps/api/geocode/xml?sensor=false&key=$self->{key}&address=$eaddr";
	debug("URL: $url");
	my $var = qx(GET "$url");
	croak("[$LIBRARY] Error message from google API:\n  $1\n\n")
		if $var =~ /<error_message>(.+)<\/error/i;
	croak("[$LIBRARY] Couldn't find location [$addr]")
		unless $var =~ /<location>(.+?)<\/location>/msg;
	$var = $1;
	croak("[$LIBRARY] Couldn't find lat [$addr]")
		unless $var =~ /<lat>(.+?)<\/lat>/msg;
	my $lat = $1;
	croak("[$LIBRARY] Couldn't find long [$addr]")
		unless $var =~ /<lng>(.+?)<\/lng>/msg;
	my $lng = $1;
	my $coords = "$lat,$lng";
	$CACHE{$eaddr} = $coords;
	wantarray ? ($coords,1) : $coords;
}

sub getCoordsV2 {
	my ($self,$addr) = @_;
	my $eaddr = escape($addr);
	return $CACHE{$eaddr} if $CACHE{$eaddr};
	my $url = "http://maps.google.com/maps/geo?q=$eaddr&sensor=false&key=$self->{key}";
	debug("URL: $url");
	my $var = qx(GET "$url");
	croak("[$LIBRARY] Error message from google API:\n  $1\n\n")
		if $var =~ /<error_message>(.+)<\/error/i;
	croak("[$LIBRARY] Couldn't find co-ords [$addr]")
		unless $var =~ /coordinates.*:\s*\[\s*(-?\d+\.\d+),\s*(-?\d+\.\d+)/;
	my $coords = "$1,$2";
	$CACHE{$eaddr} = $coords;
	wantarray ? ($coords,1) : $coords;
}

my $OPENCAGETIME = time;
sub getCoordsOpenCageData {
	my ($self,$addr) = @_;
	my $eaddr = escape($addr);
	return $CACHE{$eaddr} if $CACHE{$eaddr};
	sleep 1 unless $OPENCAGETIME+1<=time;
	$OPENCAGETIME = time;
	my $url = "https://api.opencagedata.com/geocode/v1/xml?key=$self->{key}&q=$eaddr&limit=1&no_annotations=1";
	debug("URL: $url");
	my $var = qx(GET "$url");
	croak("[$LIBRARY] Error message from opencagedata API:\n  $2\n\n")
		if $var =~ /<status>.*<code>.*?(\d+).*?<\/code>.*<message>(.+)<\/message/i && $1!=200;
	croak("[$LIBRARY] Couldn't find co-ords [$addr]")
		unless $var =~ /<geometry>.*<lat>\s*(-?\d+\.\d+)\s*<\/lat>.*<lng>(-?\d+\.\d+)\s*<\/lng>/;
	my $coords = "$1,$2";
	$CACHE{$eaddr} = $coords;
	wantarray ? ($coords,1) : $coords;
}

sub getCoords {
	return getCoordsV2(@_) if $API==2;
	return getCoordsOpenCageData(@_) if $API=~/opencage/i;
	return getCoordsV3(@_);
}

sub getKey {
	my ($self) = @_;
	$self->{key} ||= $KEY;
	return if $self->{key};
	my $keyfile = $self->{keyfile} || KeyFile();
	if (open(KEYFILE,"<$keyfile")) {
		debug("Reading keyfile: $keyfile");
		$self->{key} = <KEYFILE>;
		close KEYFILE;
		chomp($self->{key});
	} else {
		croak("[$LIBRARY] Couldn't open keyfile: $keyfile") if $self->{keyfile};
	}
	croak(<<MISSING_KEY) unless $self->{key};
[$LIBRARY] You need a Google Maps or OpenCageData API Key.
See makeKML.pm documentation for setup instructions.
MISSING_KEY
	debug("Found key: $self->{key}");
}

my %READLATLONG;
sub latlong {
	my ($self,$addr) = @_;
	getKey($self);
	readLatLongCache($self);
	my ($coords,$new) = getCoords($self,$addr),"\n";
	writeLatLongCache($self) if $new;
	return $coords unless $OFFSETDUPLICATES;
	return $coords unless $coords =~ /^$LATLONGRE$/;
	my ($lat,$long) = ($1,$2);
	if ($OFFSETDUPLICATES>0) {
		while ($READLATLONG{$coords}++) {
			$lat += $OFFSETDUPLICATES; $long += $OFFSETDUPLICATES;
			$coords = "$lat,$long";
		}
	}
	$coords;
}

##################################################
# GeoJSON object
##################################################
sub new {
	my $self = shift;
	my $class = ref($self) || $self;
	my $self = shift || {};
	bless $self, $class;
	return $self;
}

sub addIcon {
	my ($self, $style, $href) = @_;
	print STDERR "WARNING: Replacing $style with $href\n" if $STYLES{$style} && $STYLES{$style} ne $href;
	$STYLES{$style} = $href;
}

# Returns 0 on success, negative on failure (same as makeKML)
sub add {
	my ($self,$place) = @_;

	return -1 unless $place->{name};

	$place->{address} ||= $place->{addr};
	return -2 unless $place->{address} || $place->{city} || $place->{state} || $place->{coords};

	unless ($place->{coords}) {
		my $addr = $place->{address};
		$addr =~ s/\s*\@\s*/ at /g;
		$addr =~ s/\s*\([^\)]+\)\s*//g;
		$addr .= ',' if $addr && ($place->{city} || $place->{state});
		my @a;
		push(@a,$addr) if $addr;
		push(@a,$place->{city})  if $place->{city};
		push(@a,$place->{state}) if $place->{state};
		$addr = join(' ',@a);
		$addr .= ", $place->{country}" if $place->{country};
		my $latlong = $self->latlong($addr);
		# Store as lng,lat (GeoJSON coordinate order, same as KML)
		$place->{coords} = $latlong =~ /$LATLONGRE/ ? "$2,$1" : $latlong;
		chomp($place->{coords});
	}

	return -2 unless $place->{coords};

	push(@{$self->{places}}, $place);
	0;
}

##################################################
# JSON encoding
##################################################
sub jsonStr {
	my ($str) = @_;
	$str //= '';
	$str =~ s/\\/\\\\/g;
	$str =~ s/"/\\"/g;
	$str =~ s/\t/\\t/g;
	$str =~ s/\r/\\r/g;
	$str =~ s/\n/\\n/g;
	$str =~ s/([\x00-\x1f\x80-\xff])/sprintf("\\u%04x",ord($1))/ge;
	"\"$str\"";
}

sub write {
	my ($self,$file) = @_;
	$file ||= $self->{file};
	croak("[$LIBRARY] Usage:  makeGeoJSON->new(file => <file>) or \$gj->write(<file>)\n") unless $file;

	my $fh = new IO::File;
	$fh->open(">$file") || croak("[$LIBRARY] Couldn't write GeoJSON: $file\n");

	my @places = @{$self->{places}};
	print $fh "{\"type\":\"FeatureCollection\",\"features\":[\n";
	for my $i (0..$#places) {
		my $p = $places[$i];
		my ($lng, $lat) = split(/,/, $p->{coords});
		my $style = $p->{style} || 'whitepaddle';
		my $icon  = $STYLES{$style} || $STYLES{whitepaddle};
		my $link  = $p->{link} || '';
		print $fh "{\"type\":\"Feature\",";
		print $fh "\"geometry\":{\"type\":\"Point\",\"coordinates\":[$lng,$lat]},";
		print $fh "\"properties\":{";
		print $fh "\"name\":"        . jsonStr($p->{name}) . ",";
		print $fh "\"description\":" . jsonStr($p->{desc}) . ",";
		print $fh "\"link\":"        . jsonStr($link)      . ",";
		print $fh "\"icon\":"        . jsonStr($icon);
		print $fh "}}";
		print $fh "," unless $i == $#places;
		print $fh "\n";
	}
	print $fh "]}\n";
}

1;

__END__

=pod

=head1 NAME

makeGeoJSON.pm - Builds GeoJSON files for Google Maps

=head1 SYNOPSIS

Drop-in replacement for makeKML.pm:

  use makeGeoJSON;

  makeGeoJSON->latlongCache("latlong.cache");
  makeGeoJSON->useAPI('opencagedata');

  my $gj = makeGeoJSON->new({ name => "My Map" });

  $gj->add({
    name    => 'The White House',
    desc    => 'Where the president lives',
    link    => 'http://whitehouse.gov/',
    style   => 'yellowpush',
    address => '1600 Pennsylvania Ave NW',
    city    => 'Washington',
    state   => 'DC',
  });

  $gj->write("Map.json");

=head1 DESCRIPTION

Same API as makeKML.pm but writes a GeoJSON FeatureCollection instead of KML.
Each feature's C<properties> object contains C<name>, C<description>, C<link>,
and C<icon> (the resolved icon URL for use with the Google Maps Data layer).

Geocoding, caching, and API key setup are identical to makeKML.pm.

=head1 DISPLAY IN GOOGLE MAPS

Use the Google Maps Data layer instead of KmlLayer. Load the API with
C<async> and C<loading=async> to avoid deprecation warnings, and use
C<window.addEventListener> instead of the deprecated C<addDomListener>:

  <script>
  function initialize() {
    var map = new google.maps.Map(document.getElementById('map-canvas'), {
      center: { lat: 37.77, lng: -122.44 },
      zoom: 12
    });
    var time5 = parseInt((new Date().getTime()) / 5000);
    var infoWindow = new google.maps.InfoWindow();
    map.data.loadGeoJson('https://example.com/Map.json?a=' + time5);
    map.data.setStyle(function(feature) {
      return { icon: feature.getProperty('icon') };
    });
    map.data.addListener('click', function(event) {
      var name = event.feature.getProperty('name');
      var link = event.feature.getProperty('link');
      var desc = event.feature.getProperty('description');
      var title = link ? '<a href="' + link + '" target="_blank">' + name + '</a>' : name;
      infoWindow.setContent('<b>' + title + '</b><br>' + desc);
      infoWindow.setPosition(event.latLng);
      infoWindow.open(map);
    });
  }
  window.addEventListener('load', initialize);
  </script>
  <script async
    src="https://maps.googleapis.com/maps/api/js?key=YOUR_KEY&loading=async">
  </script>
  <div style="height: 500px; width: 100%;" id="map-canvas"></div>

The cache-busting C<?a=time5> parameter ensures browsers reload the JSON
when it changes. Adjust the divisor to control how often (5000 = every 5
seconds, 500000 = every ~8 minutes).

=head1 DIFFERENCES FROM makeKML

=over

=item *

Output is GeoJSON (FeatureCollection) instead of KML.

=item *

C<%STYLES> hash is correctly defined (makeKML has a C<{> vs C<(> bug that
breaks all style lookups).  C<bluepaddle> is also added here.

=item *

Descriptions are stored as-is (no KML-specific character cleaning).
The C<write()> method handles JSON encoding.

=back

=head1 COPYRIGHT

  Copyright 2004,2020,2026 David Ljung Madison Stellar L<http://GetDave.com/>
  All rights reserved.
  See: L<http://MarginalHacks.com/>

=cut
