Binning data into a hexagonal grid in Google Maps - javascript

I'm trying to display geospatial data in a hexagonal grid on a Google Map.
In order to do so, given a hexagon tile grid size X I need to be able to convert ({lat, lng}) coordinates into the ({lat, lng}) centers of the hexagon grid tiles that contain them.
In the end, I would like to be able to display data on a Google Map like this:
Does anybody have any insight into how this is done?
I've tried porting this Python hexagon binning script, binner.py to Javascript but it doesn't seem to be working properly- the output values are all the same as the input ones.
For the sake of this example, I don't care if there are multiple polygons in a single location, I just need to figure out how to bin them into the correct coordinates.
Code below, (Plunker here!)
var map;
var pointCount = 0;
var locations = [];
var gridWidth = 200000; // hex tile size in meters
var bounds;
var places = [
[44.13, -69.51],
[45.23, -67.42],
[46.33, -66.53],
[44.43, -65.24],
[46.53, -64.15],
[44.63, -63.06],
[44.73, -62.17],
[43.83, -63.28],
[44.93, -64.39],
[44.13, -65.41],
[41.23, -66.52],
[44.33, -67.63],
[42.43, -68.74],
[44.53, -69.65],
[40.63, -70.97],
]
var SQRT3 = 1.73205080756887729352744634150587236;
$(document).ready(function(){
bounds = new google.maps.LatLngBounds();
map = new google.maps.Map(document.getElementById("map_canvas"), {center: {lat: 0, lng: 0}, zoom: 2});
// Adding a marker just so we can visualize where the actual data points are.
// In the end, we want to see the hex tile that contain them
places.forEach(function(place, p){
latlng = new google.maps.LatLng({lat: place[0], lng: place[1]});
marker = new google.maps.Marker({position: latlng, map: map})
// Fitting to bounds so the map is zoomed to the right place
bounds.extend(latlng);
});
map.fitBounds(bounds);
// Now, we draw our hexagons! (or try to)
locations = makeBins(places);
locations.forEach(function(place, p){
drawHorizontalHexagon(map, place, gridWidth);
})
});
function drawHorizontalHexagon(map,position,radius){
var coordinates = [];
for(var angle= 0;angle < 360; angle+=60) {
coordinates.push(google.maps.geometry.spherical.computeOffset(position, radius, angle));
}
// Construct the polygon.
var polygon = new google.maps.Polygon({
paths: coordinates,
position: position,
strokeColor: '#FF0000',
strokeOpacity: 0.8,
strokeWeight: 2,
fillColor: '#FF0000',
fillOpacity: 0.35,
geodesic: true
});
polygon.setMap(map);
}
// Below is my attempt at porting binner.py to Javascript.
// Source: https://github.com/coryfoo/hexbins/blob/master/hexbin/binner.py
function distance(x1, y1, x2, y2){
console.log(x1, y1, x2, y2);
result = Math.sqrt(((x1 - x2) * (x1 - x2)) + ((y1 - y2) * (y1 - y2)));
console.log("Distance: ", result);
return result;
}
function nearestCenterPoint(value, scale){
div = (value / (scale / 2));
mod = value % (scale / 2);
if(div % 2 == 1){
increment = 1;
} else {
increment = 0;
}
rounded = (scale / 2) * (div + increment);
if(div % 2 === 0){
increment = 1;
} else {
increment = 0;
}
rounded_scaled = (scale / 2) * (div + increment)
result = [rounded, rounded_scaled];
return result;
}
function makeBins(data){
bins = [];
data.forEach(function(place, p){
x = place[0];
y = place[1];
console.log("Original location:", x, y);
px_nearest = nearestCenterPoint(x, gridWidth);
py_nearest = nearestCenterPoint(y, gridWidth * SQRT3);
z1 = distance(x, y, px_nearest[0], py_nearest[0]);
z2 = distance(x, y, px_nearest[1], py_nearest[1]);
console.log(z1, z2);
if(z1 > z2){
bin = new google.maps.LatLng({lat: px_nearest[0], lng: py_nearest[0]});
console.log("Final location:", px_nearest[0], py_nearest[0]);
} else {
bin = new google.maps.LatLng({lat: px_nearest[1], lng: py_nearest[1]});
console.log("Final location:", px_nearest[1], py_nearest[1]);
}
bins.push(bin);
})
return bins;
}

Use google.maps.geometry.poly.containsLocation.
for (var i = 0; i < hexgrid.length; i++) {
if (google.maps.geometry.poly.containsLocation(place, hexgrid[i])) {
if (!hexgrid[i].contains) {
hexgrid[i].contains = 0;
}
hexgrid[i].contains++
}
}
Example based off this related question: How can I make a Google Maps API v3 hexagon tiled map, preferably coordinate-based?. The number in the white box in the center of each hexagon is the number of markers contained by it.
proof of concept fiddle
code snippet:
var map = null;
var hexgrid = [];
function initMap() {
var myOptions = {
zoom: 8,
center: new google.maps.LatLng(43, -79.5),
mapTypeControl: true,
mapTypeControlOptions: {
style: google.maps.MapTypeControlStyle.DROPDOWN_MENU
},
navigationControl: true,
mapTypeId: google.maps.MapTypeId.ROADMAP
}
map = new google.maps.Map(document.getElementById("map"),
myOptions);
createHexGrid();
var bounds = new google.maps.LatLngBounds();
// Seed our dataset with random locations
for (var i = 0; i < hexgrid.length; i++) {
var hexbounds = new google.maps.LatLngBounds();
for (var j = 0; j < hexgrid[i].getPath().getLength(); j++) {
bounds.extend(hexgrid[i].getPath().getAt(j));
hexbounds.extend(hexgrid[i].getPath().getAt(j));
}
hexgrid[i].bounds = hexbounds;
}
var span = bounds.toSpan();
var locations = [];
for (pointCount = 0; pointCount < 50; pointCount++) {
place = new google.maps.LatLng(Math.random() * span.lat() + bounds.getSouthWest().lat(), Math.random() * span.lng() + bounds.getSouthWest().lng());
bounds.extend(place);
locations.push(place);
var mark = new google.maps.Marker({
map: map,
position: place
});
// bin points in hexgrid
for (var i = 0; i < hexgrid.length; i++) {
if (google.maps.geometry.poly.containsLocation(place, hexgrid[i])) {
if (!hexgrid[i].contains) {
hexgrid[i].contains = 0;
}
hexgrid[i].contains++
}
}
}
// add labels
for (var i = 0; i < hexgrid.length; i++) {
if (typeof hexgrid[i].contains == 'undefined') {
hexgrid[i].contains = 0;
}
var labelText = "<div style='background-color:white'>" + hexgrid[i].contains + "</div>";
var myOptions = {
content: labelText,
boxStyle: {
border: "1px solid black",
textAlign: "center",
fontSize: "8pt",
width: "20px"
},
disableAutoPan: true,
pixelOffset: new google.maps.Size(-10, 0),
position: hexgrid[i].bounds.getCenter(),
closeBoxURL: "",
isHidden: false,
pane: "floatPane",
enableEventPropagation: true
};
var ibLabel = new InfoBox(myOptions);
ibLabel.open(map);
}
}
function createHexGrid() {
// === Hexagonal grid ===
var point = new google.maps.LatLng(42, -78.8);
map.setCenter(point);
var hex1 = google.maps.Polygon.RegularPoly(point, 25000, 6, 90, "#000000", 1, 1, "#00ff00", 0.5);
hex1.setMap(map);
var d = 2 * 25000 * Math.cos(Math.PI / 6);
hexgrid.push(hex1);
var hex30 = google.maps.Polygon.RegularPoly(EOffsetBearing(point, d, 30), 25000, 6, 90, "#000000", 1, 1, "#00ffff", 0.5);
hex30.setMap(map);
hexgrid.push(hex30);
var hex90 = google.maps.Polygon.RegularPoly(EOffsetBearing(point, d, 90), 25000, 6, 90, "#000000", 1, 1, "#ffff00", 0.5);
hex90.setMap(map);
hexgrid.push(hex90);
var hex150 = google.maps.Polygon.RegularPoly(EOffsetBearing(point, d, 150), 25000, 6, 90, "#000000", 1, 1, "#00ffff", 0.5);
hex150.setMap(map);
hexgrid.push(hex150);
var hex210 = google.maps.Polygon.RegularPoly(EOffsetBearing(point, d, 210), 25000, 6, 90, "#000000", 1, 1, "#ffff00", 0.5);
hex210.setMap(map);
hexgrid.push(hex210);
hex270 = google.maps.Polygon.RegularPoly(EOffsetBearing(point, d, 270), 25000, 6, 90, "#000000", 1, 1, "#ffff00", 0.5);
hex270.setMap(map);
hexgrid.push(hex270);
var hex330 = google.maps.Polygon.RegularPoly(EOffsetBearing(point, d, 330), 25000, 6, 90, "#000000", 1, 1, "#ffff00", 0.5);
hex330.setMap(map);
hexgrid.push(hex330);
var hex30_2 = google.maps.Polygon.RegularPoly(EOffsetBearing(EOffsetBearing(point, d, 30), d, 90), 25000, 6, 90, "#000000", 1, 1, "#ff0000", 0.5);
hex30_2.setMap(map);
hexgrid.push(hex30_2);
var hex150_2 = google.maps.Polygon.RegularPoly(EOffsetBearing(EOffsetBearing(point, d, 150), d, 90), 25000, 6, 90, "#000000", 1, 1, "#0000ff", 0.5);
hex150_2.setMap(map);
hexgrid.push(hex150_2);
var hex90_2 = google.maps.Polygon.RegularPoly(EOffsetBearing(EOffsetBearing(point, d, 90), d, 90), 25000, 6, 90, "#000000", 1, 1, "#00ff00", 0.5);
hex90_2.setMap(map);
hexgrid.push(hex90_2);
// This Javascript is based on code provided by the
// Community Church Javascript Team
// http://www.bisphamchurch.org.uk/
// http://econym.org.uk/gmap/
//]]>
}
google.maps.event.addDomListener(window, 'load', initMap);
// EShapes.js
//
// Based on an idea, and some lines of code, by "thetoy"
//
// This Javascript is provided by Mike Williams
// Community Church Javascript Team
// http://www.bisphamchurch.org.uk/
// http://econym.org.uk/gmap/
//
// This work is licenced under a Creative Commons Licence
// http://creativecommons.org/licenses/by/2.0/uk/
//
// Version 0.0 04/Apr/2008 Not quite finished yet
// Version 1.0 10/Apr/2008 Initial release
// Version 3.0 12/Oct/2011 Ported to v3 by Lawrence Ross
google.maps.Polygon.Shape = function(point, r1, r2, r3, r4, rotation, vertexCount, strokeColour, strokeWeight, Strokepacity, fillColour, fillOpacity, opts, tilt) {
var rot = -rotation * Math.PI / 180;
var points = [];
var latConv = google.maps.geometry.spherical.computeDistanceBetween(point, new google.maps.LatLng(point.lat() + 0.1, point.lng())) * 10;
var lngConv = google.maps.geometry.spherical.computeDistanceBetween(point, new google.maps.LatLng(point.lat(), point.lng() + 0.1)) * 10;
var step = (360 / vertexCount) || 10;
var flop = -1;
if (tilt) {
var I1 = 180 / vertexCount;
} else {
var I1 = 0;
}
for (var i = I1; i <= 360.001 + I1; i += step) {
var r1a = flop ? r1 : r3;
var r2a = flop ? r2 : r4;
flop = -1 - flop;
var y = r1a * Math.cos(i * Math.PI / 180);
var x = r2a * Math.sin(i * Math.PI / 180);
var lng = (x * Math.cos(rot) - y * Math.sin(rot)) / lngConv;
var lat = (y * Math.cos(rot) + x * Math.sin(rot)) / latConv;
points.push(new google.maps.LatLng(point.lat() + lat, point.lng() + lng));
}
return (new google.maps.Polygon({
paths: points,
strokeColor: strokeColour,
strokeWeight: strokeWeight,
strokeOpacity: Strokepacity,
fillColor: fillColour,
fillOpacity: fillOpacity
}))
}
google.maps.Polygon.RegularPoly = function(point, radius, vertexCount, rotation, strokeColour, strokeWeight, Strokepacity, fillColour, fillOpacity, opts) {
rotation = rotation || 0;
var tilt = !(vertexCount & 1);
return google.maps.Polygon.Shape(point, radius, radius, radius, radius, rotation, vertexCount, strokeColour, strokeWeight, Strokepacity, fillColour, fillOpacity, opts, tilt)
}
function EOffsetBearing(point, dist, bearing) {
var latConv = google.maps.geometry.spherical.computeDistanceBetween(point, new google.maps.LatLng(point.lat() + 0.1, point.lng())) * 10;
var lngConv = google.maps.geometry.spherical.computeDistanceBetween(point, new google.maps.LatLng(point.lat(), point.lng() + 0.1)) * 10;
var lat = dist * Math.cos(bearing * Math.PI / 180) / latConv;
var lng = dist * Math.sin(bearing * Math.PI / 180) / lngConv;
return new google.maps.LatLng(point.lat() + lat, point.lng() + lng)
}
html,
body,
#map {
height: 100%;
width: 100%;
margin: 0px;
padding: 0px
}
<script src="https://maps.googleapis.com/maps/api/js?libraries=geometry&key=AIzaSyCkUOdZ5y7hMm0yrcCQoCvLwzdM6M8s5qk"></script>
<script src="https://google-maps-utility-library-v3.googlecode.com/svn/trunk/infobox/src/infobox.js"></script>
<div id="map"></div>

Take a look at deck.gl and its hexagon layer
https://deck.gl/docs/api-reference/geo-layers/h3-hexagon-layer
With the getHexagon function, you can retrieve the H3 hexagon index of each object.
Then, you can use H3's cellToLatLng function to retrieve the hexagon cell's center coordinates.
But actually, you won't need this step if you just want to create a hexagonal data grid like the one you have shown in the image. You can simply feed the data into the deck.gl hexagon layer and the framework will do the rest to produce the output you want.

Related

Adjusting lines positions on polygon

I am using FabricJS to generate a polygon with 6 points
var polygon = new fabric.Polygon([
new fabric.Point(150, 50),
new fabric.Point(250, 50),
new fabric.Point(250, 150),
new fabric.Point(150, 150),
new fabric.Point(50, 250),
]);
On each point I need to have a line, so I am iterating through points and adding lines successfully:
return new fabric.Line([p.x, p.y, fromX, fromY], {
stroke: colors[i],
strokeWidth: 10,
hasBorders: true,
strokeDashArray: [20, 5]
});
this code generates polygon successfully with my desired coloured line:
The issue is now I want to adjust lines positions, for example the red line I need to be sticky on top of the polygon, the green one I need it half inside of polygon, blue 5% distant from polygon, etc.
While searching I found, Check if Point Is Inside A Polygon and tried those functions, which gives different results, so I have two functions, isinPolygonFirst and isinPolygonSecond, which basically are checking if my point is inside polygon. I thought If I could know that if my points are inside polygon I can add some value, for example if point was [20,20 and if it is inside polygon I can increase to [21,21] and recheck if it is. I am posting debugging info(in snippet below needs to move polygon box to see debug messages):
// function to check if line exists in polygon
function isinPolygonFirst(points, longitude_x, latitude_y) {
vertices_y = new Array();
vertices_x = new Array();
var r = 0;
var i = 0;
var j = 0;
var c = 0;
var point = 0;
for (r = 0; r < points.length; r++) {
vertices_y.push(points[r].y);
vertices_x.push(points[r].x);
}
points_polygon = vertices_x.length;
for (i = 0, j = points_polygon; i < points_polygon; j = i++) {
point = i;
if (point == points_polygon)
point = 0;
if (((vertices_y[point] > latitude_y != (vertices_y[j] > latitude_y)) && (longitude_x < (vertices_x[j] - vertices_x[point]) * (latitude_y - vertices_y[point]) / (vertices_y[j] - vertices_y[point]) + vertices_x[point])))
c = !c;
}
return c;
}
// other function to check if line exist in polygon
function isinPolygonSecond(points, x, y) {
cornersX = new Array();
cornersY = new Array();
for (r = 0; r < points.length; r++) {
cornersX.push(points[r].x);
cornersY.push(points[r].y);
}
var i, j = cornersX.length - 1;
var odd = false;
var pX = cornersX;
var pY = cornersY;
for (i = 0; i < cornersX.length; i++) {
if ((pY[i] < y && pY[j] >= y || pY[j] < y && pY[i] >= y) &&
(pX[i] <= x || pX[j] <= x)) {
odd ^= (pX[i] + (y - pY[i]) * (pX[j] - pX[i]) / (pY[j] - pY[i])) < x;
}
j = i;
}
return odd;
}
var canvas = new fabric.Canvas("c", {
selection: false
});
var points = [];
var polygon = new fabric.Polygon([
new fabric.Point(150, 50),
new fabric.Point(250, 50),
new fabric.Point(250, 150),
new fabric.Point(150, 150),
new fabric.Point(50, 250),
]);
polygon.on("modified", function() {
//document.getElementById("p").innerHTML = JSON.stringify(this)+ "<br>";
var matrix = this.calcTransformMatrix();
var transformedPoints = this.get("points")
.map(function(p) {
return new fabric.Point(
p.x - polygon.pathOffset.x,
p.y - polygon.pathOffset.y);
})
.map(function(p) {
return fabric.util.transformPoint(p, matrix);
});
var circles = transformedPoints.map(function(p) {
return new fabric.Circle({
left: p.x,
top: p.y,
radius: 3,
fill: "red",
originX: "center",
originY: "center",
hasControls: false,
hasBorders: false,
selectable: false
});
});
//Lines Colors
var colors = ['red', 'green', 'blue', 'violet', 'teal', 'brown'];
//I need these distances from the polygon, where is negative value it should go inside polygon
var LinesDistances = [10, -5, -20, 0, 20, 40];
var lines = transformedPoints.map(function(p, i) {
var po = (i < polygon.points.length) ? i + 1 : 0;
if (typeof transformedPoints[po] === 'undefined')
po = 0;
var fromX = transformedPoints[po].x;
var fromY = transformedPoints[po].y;
var isinPolygon = isinPolygonFirst(transformedPoints, fromX, fromY);
var isinPolygon2 = isinPolygonSecond(transformedPoints, fromX, fromY);
var debug = '';
debug += 'fromX:' + fromX;
debug += ' ,fromY :' + fromY;
debug += ' ,isinPolygon:' + isinPolygon;
debug += ' ,isinPolygonSecond:' + isinPolygon2;
document.getElementById("p").innerHTML += '<p style="color:#fff;background:' + colors[i] + '"> ' + debug + "</p>";
return new fabric.Line([p.x, p.y, fromX, fromY], {
stroke: colors[i],
strokeWidth: 10,
hasBorders: true,
strokeDashArray: [20, 5]
});
});
this.canvas.clear().add(this).add.apply(this.canvas, lines).add.apply(this.canvas, circles).setActiveObject(this).renderAll();
polygon.set({
opacity: 0.5
});
polygon.sendToBack();
});
canvas.add(polygon).renderAll();
canvas {
border: 1px solid;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/3.6.2/fabric.min.js"></script>
<div id="p"></div>
<canvas id="c" width="600" height="400"></canvas>
I am not certain if it's the right approach I am moving to, I wants to dynamically distance lines around the polygon as it will be required.
I need these distances from the polygon, where is negative value it should go inside polygon
var LinesDistances = [10, -5, -20, 0, 20, 40];
Here is fiddle and code. at https://jsfiddle.net/DivMaster/0e34Lnyx/211/
Any help? Thanks <3
I was able to solve it by using Intersects library available at https://github.com/davidfig/intersects
I calculated the midpoint of line and performed plus/minus operations on x/y points to see if points reside in polygon
pointPolygon(x1, y1, points)

Snap circle to a polyline in Leaflet

I have a polyline in leaflet on which I want to snap center a circle with which I want to move among the polyline so the circle is always at the center of the polyline.
Is there a way how to snap center of circle into center so when I move the circle it always centers on the polyline?
I've made a fiddle, where the circle is moved just on mousemove event: http://jsfiddle.net/v0bseuqz/32/
The main idea is to create a line (when mouse is moved) that strokes cursor coordinates from the top of the map to the bottom, and check if it intersects the polyline to which the circle should be snapped. It they intersect, their intersection should be new centre of the circle.
document.onload = loadMap();
function loadMap() {
var map = L.map('map').setView([0, 0], 12);
L.tileLayer('https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token=pk.eyJ1IjoibWFwYm94IiwiYSI6ImNpandmbXliNDBjZWd2M2x6bDk3c2ZtOTkifQ._QA7i5Mpkd_m30IGElHziw', {
maxZoom: 18,
id: 'mapbox.streets',
accessToken: 'pk.eyJ1IjoiZW======V6ZTdlb2V5cyJ9.3HqHQ4BMRvSPaYe8ToA7YQ'
}).addTo(map);
L.polyline([
[-50, 1000],
[0, 0]
], {
color: 'red',
weight: 1
}).addTo(map);
var circle = L.circle([0, 0], 500, {
color: 'red',
fillColor: '#f03',
fillOpacity: 0.5
}).addTo(map);
$( "#map" ).mousemove(function( event ) {
var cursorPoint = new L.Point(event.clientX, event.clientY);
var cursorLatLng = map.containerPointToLatLng(cursorPoint);
var intersection = (checkLineIntersection(0, 0, 1000, -50, cursorLatLng.lng, 1, cursorLatLng.lng, -1));
if (intersection.onLine1 && intersection.onLine2) {
circle.setLatLng(new L.LatLng(intersection.y, intersection.x));
}
});
}
function checkLineIntersection(line1StartX, line1StartY, line1EndX, line1EndY, line2StartX, line2StartY, line2EndX, line2EndY) {
// if the lines intersect, the result contains the x and y of the intersection (treating the lines as infinite) and booleans for whether line segment 1 or line segment 2 contain the point
var denominator, a, b, numerator1, numerator2, result = {
x: null,
y: null,
onLine1: false,
onLine2: false
};
denominator = ((line2EndY - line2StartY) * (line1EndX - line1StartX)) - ((line2EndX - line2StartX) * (line1EndY - line1StartY));
if (denominator == 0) {
return result;
}
a = line1StartY - line2StartY;
b = line1StartX - line2StartX;
numerator1 = ((line2EndX - line2StartX) * a) - ((line2EndY - line2StartY) * b);
numerator2 = ((line1EndX - line1StartX) * a) - ((line1EndY - line1StartY) * b);
a = numerator1 / denominator;
b = numerator2 / denominator;
// if we cast these lines infinitely in both directions, they intersect here:
result.x = line1StartX + (a * (line1EndX - line1StartX));
result.y = line1StartY + (a * (line1EndY - line1StartY));
/*
// it is worth noting that this should be the same as:
x = line2StartX + (b * (line2EndX - line2StartX));
y = line2StartX + (b * (line2EndY - line2StartY));
*/
// if line1 is a segment and line2 is infinite, they intersect if:
if (a > 0 && a < 1) {
result.onLine1 = true;
}
// if line2 is a segment and line1 is infinite, they intersect if:
if (b > 0 && b < 1) {
result.onLine2 = true;
}
// if line1 and line2 are segments, they intersect if both of the above are true
return result;
};
UPDATE
If you have a polyline that is defined by more than two points, you should check intersection of it's every segment with the perpendicular line. Jut do it in a loop. Here is the fiddle: http://jsfiddle.net/v0bseuqz/39/
var coords = [
[0.003, 0.080],
[-0.008, 0.041],
[0, 0]
];
L.polyline(coords, {
color: 'red',
weight: 1
}).addTo(map);
var circle = L.circle([0, 0], 500, {
color: 'red',
fillColor: '#f03',
fillOpacity: 0.5
}).addTo(map);
var startY, startX, endY, endX, cursorPoint, cursorLatLng, intersection;
$( "#map" ).mousemove(function( event ) {
for(i = coords.length, i >0; i--;) {
if (i - 1 >= 0) {
startY = coords[i][0];
startX = coords[i][1];
endY = coords[i - 1][0];
endX = coords[i - 1][1];
cursorPoint = new L.Point(event.clientX, event.clientY);
cursorLatLng = map.containerPointToLatLng(cursorPoint);
intersection = (checkLineIntersection(startX, startY, endX, endY, cursorLatLng.lng, 1, cursorLatLng.lng, -1));
if (intersection.onLine1 && intersection.onLine2) {
circle.setLatLng(new L.LatLng(intersection.y, intersection.x));
}
}
}
});
I've taken the algorithm of detecting the intersection of two lines here.

google maps circle to polyline coordinate array

how to get array of polyline coordinates from google.maps.Circle's object
there is no api doc entry about that
A google.maps.Circle doesn't contain an array of coordinates. If you want a google.maps.Polygon that is shaped like a circle, you need to make one.
function drawCircle(point, radius, dir) {
var d2r = Math.PI / 180; // degrees to radians
var r2d = 180 / Math.PI; // radians to degrees
var earthsradius = 3963; // 3963 is the radius of the earth in miles
var points = 32;
// find the raidus in lat/lon
var rlat = (radius / earthsradius) * r2d;
var rlng = rlat / Math.cos(point.lat() * d2r);
var extp = new Array();
if (dir==1) {
var start=0;
var end=points+1; // one extra here makes sure we connect the path
} else {
var start=points+1;
var end=0;
}
for (var i=start; (dir==1 ? i < end : i > end); i=i+dir)
{
var theta = Math.PI * (i / (points/2));
ey = point.lng() + (rlng * Math.cos(theta)); // center a + radius x * cos(theta)
ex = point.lat() + (rlat * Math.sin(theta)); // center b + radius y * sin(theta)
extp.push(new google.maps.LatLng(ex, ey));
}
return extp;
}
var circle = new google.maps.Polygon({
map: map,
paths: [drawCircle(new google.maps.LatLng(-33.9,151.2), 100, 1)],
strokeColor: "#0000FF",
strokeOpacity: 0.8,
strokeWeight: 2,
fillColor: "#FF0000",
fillOpacity: 0.35
});
Example
code snippet:
function drawCircle(point, radius, dir) {
var d2r = Math.PI / 180; // degrees to radians
var r2d = 180 / Math.PI; // radians to degrees
var earthsradius = 3963; // 3963 is the radius of the earth in miles
var points = 32;
// find the raidus in lat/lon
var rlat = (radius / earthsradius) * r2d;
var rlng = rlat / Math.cos(point.lat() * d2r);
var extp = new Array();
if (dir == 1) {
var start = 0;
var end = points + 1
} // one extra here makes sure we connect the
else {
var start = points + 1;
var end = 0
}
for (var i = start;
(dir == 1 ? i < end : i > end); i = i + dir) {
var theta = Math.PI * (i / (points / 2));
ey = point.lng() + (rlng * Math.cos(theta)); // center a + radius x * cos(theta)
ex = point.lat() + (rlat * Math.sin(theta)); // center b + radius y * sin(theta)
extp.push(new google.maps.LatLng(ex, ey));
bounds.extend(extp[extp.length - 1]);
}
// alert(extp.length);
return extp;
}
var map = null;
var bounds = null;
function initialize() {
var myOptions = {
zoom: 10,
center: new google.maps.LatLng(-33.9, 151.2),
mapTypeControl: true,
mapTypeControlOptions: {
style: google.maps.MapTypeControlStyle.DROPDOWN_MENU
},
navigationControl: true,
mapTypeId: google.maps.MapTypeId.ROADMAP
}
map = new google.maps.Map(document.getElementById("map_canvas"),
myOptions);
bounds = new google.maps.LatLngBounds();
var donut = new google.maps.Polygon({
paths: [drawCircle(new google.maps.LatLng(-33.9, 151.2), 100, 1),
drawCircle(new google.maps.LatLng(-33.9, 151.2), 50, -1)
],
strokeColor: "#0000FF",
strokeOpacity: 0.8,
strokeWeight: 2,
fillColor: "#FF0000",
fillOpacity: 0.35
});
donut.setMap(map);
map.fitBounds(bounds);
}
google.maps.event.addDomListener(window, 'load', initialize);
html,
body,
#map_canvas {
width: 100%;
height: 100%;
padding: 0px;
margin: 0px;
}
<script src="https://maps.google.com/maps/api/js?key=AIzaSyCkUOdZ5y7hMm0yrcCQoCvLwzdM6M8s5qk"></script>
<div id="map_canvas"></div>
I implement this inside autocomplete, but, can't understand this: var points = 32; how calculate this variable?
createAutocomplete() {
var drawer = this.drawCircle;
var MAPS = window.Heatmap.Maps;
const center = MAPS.map.center;
const defaultBounds = {
north: center.lat + 0.1,
south: center.lat - 0.1,
east: center.lng + 0.1,
west: center.lng - 0.1,
};
let input = document.getElementById("search");
const options = {
bounds: defaultBounds,
componentRestrictions: {country: "us"},
fields: ["address_components", "geometry", "icon", "name"],
origin: MAPS.map.center,
strictBounds: false,
types: ['(cities)'],
};
MAPS.autocomplete = new google.maps.places.Autocomplete(input, options);
google.maps.event.addListener(MAPS.autocomplete, 'place_changed', function () {
const selectedIndex = document.getElementById("distance").selectedIndex;
let distance = document.getElementsByTagName("option")[selectedIndex].value;
if (MAPS.circle.instance) {
MAPS.circle.instance.setMap(null);
MAPS.marker.collection['searchMarker'].setMap(null);
}
let place = MAPS.autocomplete.getPlace();
var center = {lat: place.geometry.location.lat(), lng: place.geometry.location.lng()};
MAPS.map.instance.panTo(center);
MAPS.map.instance.setZoom(7);
MAPS.marker.collection['searchMarker'] = new google.maps.Marker({
position: center,
map: MAPS.map.instance,
icon: {
url: "http://maps.google.com/mapfiles/ms/icons/yellow-dot.png"
}
});
MAPS.circle.instance = new google.maps.Polygon({
map: MAPS.map.instance,
paths: [drawer(center, distance, 1)],
strokeColor: "#0000FF",
strokeOpacity: 0.8,
strokeWeight: 2,
fillColor: "#FF0000",
fillOpacity: 0.35
});
console.log(MAPS.circle.instance.getPath())
});
}
drawCircle(point, radius, dir) {
var d2r = Math.PI / 180;
var r2d = 180 / Math.PI;
var earthsradius = 3963;
var points = 32;
var rlat = (radius / earthsradius) * r2d;
var rlng = rlat / Math.cos(point.lat * d2r);
var extp = [];
var start = null;
var end = null;
if (dir === 1) {
start = 0;
end = points + 1;
} else {
start = points + 1;
end = 0;
}
for (var i = start; (dir === 1 ? i < end : i > end); i = i + dir) {
var theta = Math.PI * (i / (points / 2));
var ey = point.lng + (rlng * Math.cos(theta));
var ex = point.lat + (rlat * Math.sin(theta));
extp.push(new google.maps.LatLng(ex, ey));
}
return extp;
}

Draw Rectangles in Area on Google Maps

I'm drawing an area on Google Maps using the Geometry API. I want to know if it is possible to draw a repeating element onto an area that is dynamic in size?
For example, if I draw my area to look like this:
Then I want to be able to hit 'Next Step' and see something like this, with the rectangles drawn in the area, but only if they will fit. i.e., they have to be 'full' rectangles, not part rectangles:
The only problem is, I'm not entirely sure how to go about this. I would use HTML5 <canvas> but unfortunately, this needs to be as browser-friendly as possible, but if it has to be <canvas> then so be it!
Although I didn't use canvas, how about this code?
function onPolygonComplete(polygon) {
var bounds, paths, sw, ne, ystep, xstep,
boxH, boxW, posArry, flag, pos,
x, y, i, box, maxBoxCnt;
//Delete old boxes.
boxes.forEach(function(box, i) {
box.setMap(null);
delete box;
});
//Calculate the bounds that contains entire polygon.
bounds = new google.maps.LatLngBounds();
paths = polygon.getPath();
paths.forEach(function(latlng, i){
bounds.extend(latlng);
});
//Calculate the small box size.
maxBoxCnt = 8;
sw = bounds.getSouthWest();
ne = bounds.getNorthEast();
ystep = Math.abs(sw.lat() - ne.lat()) / maxBoxCnt;
boxH = Math.abs(sw.lat() - ne.lat()) / (maxBoxCnt + 1);
xstep = Math.abs(sw.lng() - ne.lng()) / maxBoxCnt;
boxW = Math.abs(sw.lng() - ne.lng()) / (maxBoxCnt + 1);
for (y = 0; y < maxBoxCnt; y++) {
for (x = 0; x < maxBoxCnt; x++) {
//Detect that polygon is able to contain a small box.
bounds = new google.maps.LatLngBounds();
posArry = [];
posArry.push(new google.maps.LatLng(sw.lat() + ystep * y, sw.lng() + xstep * x));
posArry.push(new google.maps.LatLng(sw.lat() + ystep * y, sw.lng() + xstep * x + boxW));
posArry.push(new google.maps.LatLng(sw.lat() + ystep * y + boxH, sw.lng() + xstep * x));
posArry.push(new google.maps.LatLng(sw.lat() + ystep * y + boxH, sw.lng() + xstep * x + boxW));
flag = true;
for (i = 0; i < posArry.length; i++) {
pos = posArry[i];
if (flag) {
flag = google.maps.geometry.poly.containsLocation(pos, polygon);
bounds.extend(pos);
}
}
//Draw a small box.
if (flag) {
box = new google.maps.Rectangle({
bounds : bounds,
map : mapCanvas,
strokeColor: '#00ffff',
strokeOpacity: 0.5,
strokeWeight: 1,
fillColor: '#00ffff',
fillOpacity : 0.5,
clickable: false
});
boxes.push(box);
}
}
}
}
This code works like this image.
I wrote a page that explains the code.
http://googlemaps.googlermania.com/google_maps_api_v3/en/poly_containsLocation.html
#Joshua M
Sorry for keeping you wait.
Ok, the new code is below.
You can specify the small box size at var boxSize = new google.maps.Size(10, 20);
<!DOCTYPE html>
<html>
<head>
<script src="http://maps.googleapis.com/maps/api/js?sensor=false&libraries=geometry"></script>
<script type='text/javascript'>
var mapCanvas, boxes = new google.maps.MVCArray();
function initialize() {
var mapDiv = document.getElementById("map_canvas");
mapCanvas = new google.maps.Map(mapDiv, {
center : new google.maps.LatLng(37.422191,-122.084585),
mapTypeId : google.maps.MapTypeId.SATELLITE,
zoom : 19,
tilt : 0
});
//Encoded path
var encodedPath = "eblcFnuchVv#D#q#P?a#eD]AC~#b#DCz#a#A";
var points = google.maps.geometry.encoding.decodePath(encodedPath);
//Draw a polygon
var polygonOpts = {
paths : points,
strokeWeight : 6,
strokeColor : "#FF0000",
strokeOpacity : 1,
//fillColor : "blue",
fillOpacity : 0,
map : mapCanvas,
editable : true
};
var poly = new google.maps.Polygon(polygonOpts);
var proc = function() {
onPolygonComplete(poly);
};
google.maps.event.addListener(mapCanvas, "projection_changed", proc);
google.maps.event.addListener(poly.getPath(), 'insert_at', proc);
google.maps.event.addListener(poly.getPath(), 'remove_at', proc);
google.maps.event.addListener(poly.getPath(), 'set_at', proc);
}
function onDrawMgr_complete(polygon) {
var path = polygon.getPath();
console.log(google.maps.geometry.encoding.encodePath(path));
}
function onPolygonComplete(polygon) {
var bounds, paths, sw, ne, ystep, xstep, boxH, boxW, posArry, flag, pos, x, y, i, box;
//Delete old boxes.
boxes.forEach(function(box, i) {
box.setMap(null);
delete box;
});
//Calculate the bounds that contains entire polygon.
bounds = new google.maps.LatLngBounds();
paths = polygon.getPath();
paths.forEach(function(latlng, i) {
bounds.extend(latlng);
});
var projection = mapCanvas.getProjection();
var zoom = mapCanvas.getZoom();
var powBase = Math.pow(2, zoom);
//Calculate the small box size.
sw = bounds.getSouthWest();
ne = bounds.getNorthEast();
var swPoint = projection.fromLatLngToPoint(sw);
var nePoint = projection.fromLatLngToPoint(ne);
var boxSize = new google.maps.Size(10, 20); //in pixels.
boxSize.width /= powBase;
boxSize.height /= powBase;
var maxX = Math.floor(Math.abs((swPoint.x - nePoint.x)) / boxSize.width);
var maxY = Math.floor(Math.abs((swPoint.y - nePoint.y)) / boxSize.height);
for ( y = 0; y < maxY; y++) {
for (x = 0; x < maxX; x++) {
//Detect that polygon is able to contain a small box.
bounds = new google.maps.LatLngBounds();
posArry = [];
posArry.push(new google.maps.Point(swPoint.x + boxSize.width * x, swPoint.y - boxSize.height * y));
posArry.push(new google.maps.Point(swPoint.x + boxSize.width * x, swPoint.y - boxSize.height * (y + 1)));
posArry.push(new google.maps.Point(swPoint.x + boxSize.width * (x + 1), swPoint.y - boxSize.height * y));
posArry.push(new google.maps.Point(swPoint.x + boxSize.width * (x + 1), swPoint.y - boxSize.height * (y + 1)));
var flag = true;
for (var i = 0; i < posArry.length; i++) {
pos = projection.fromPointToLatLng(posArry[i]);
if (flag) {
flag = google.maps.geometry.poly.containsLocation(pos, polygon);
bounds.extend(pos);
}
}
if (flag) {
box = new google.maps.Rectangle({
bounds : bounds,
map : mapCanvas,
strokeColor : 'green',
strokeOpacity : 1,
strokeWeight : 1,
fillColor : 'yellow',
fillOpacity : 0.5,
clickable : false
});
boxes.push(box);
}
}
}
}
google.maps.event.addDomListener(window, "load", initialize);
</script>
<style type="text/css">
window,html,#map_canvas{
width : 700px;
height : 500px;
}
</style>
</head>
<body>
<div id="map_canvas"/>
</body>
</html>

Multiple circles -> One Polygon?

Using Google Maps API v3, I was able to create multiple google.maps.Circle objects on my map. However, I now need to "connect" them somehow. I have the following map with multiple circles:
I now need to get it to look something like this:
(source: pcwp.com)
I've looked all over the Internet for solutions, but to no avail. Any ideas?
You may want to consider tackling this problem by adding addional circles at x intervals with increasing radiuses between each point of the path. This would be very easy to implement and will work for any direction of the cyclone. Obviously Matti's suggested solution to create a polygon by connecting all the tangents would be more accurate, but you can consider this as an possible alternative. The main downside is that it may require some effort to make it look pretty, and it will obviously use more client-side resources than if you were to render a single polygon.
Let's start by recreating your map:
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8"/>
<title>Google Maps Cyclones</title>
<script src="http://maps.google.com/maps/api/js?sensor=false"
type="text/javascript"></script>
</head>
<body>
<div id="map" style="width: 600px; height: 400px"></div>
<script type="text/javascript">
var i;
var mapOptions = {
mapTypeId: google.maps.MapTypeId.TERRAIN,
center: new google.maps.LatLng(28.50, -81.50),
zoom: 5
};
var map = new google.maps.Map(document.getElementById("map"),
mapOptions);
var pathPoints = [
new google.maps.LatLng(25.48, -71.26),
new google.maps.LatLng(25.38, -73.70),
new google.maps.LatLng(25.28, -77.00),
new google.maps.LatLng(25.24, -80.11),
new google.maps.LatLng(25.94, -82.71),
new google.maps.LatLng(27.70, -87.14)
];
pathPoints[0].radius = 80;
pathPoints[1].radius = 100;
pathPoints[2].radius = 200;
pathPoints[3].radius = 300;
pathPoints[4].radius = 350;
pathPoints[5].radius = 550;
new google.maps.Polyline({
path: pathPoints,
strokeColor: '#00FF00',
strokeOpacity: 1.0,
strokeWeight: 3,
map: map
});
for (i = 0; i < pathPoints.length; i++) {
new google.maps.Circle({
center: pathPoints[i],
radius: pathPoints[i].radius * 1000,
fillColor: '#FF0000',
fillOpacity: 0.2,
strokeOpacity: 0.5,
strokeWeight: 1,
map: map
});
}
</script>
</body>
</html>
Google Maps Cyclones - Figure 1 http://img186.imageshack.us/img186/1197/mp1h.png
I assume that you heave already arrived to this point, and therefore the above example should be self-explanatory. Basically we have just defined 6 points, along with 6 radiuses, and we have rendered the circles on the map, together with the green path.
Before we continue, we need to define a few methods to be able to calculate the distance and the bearing from one point to another. We will also need a method that will return the destination point when given a bearing and the distance travelled from a source point. Fortunately, there is a very good JavaScript implementation for these methods by Chris Veness at Calculate distance, bearing and more between Latitude/Longitude points. The following methods have been adapted to work with Google's google.maps.LatLng:
Number.prototype.toRad = function() {
return this * Math.PI / 180;
}
Number.prototype.toDeg = function() {
return this * 180 / Math.PI;
}
google.maps.LatLng.prototype.destinationPoint = function(brng, dist) {
dist = dist / 6371;
brng = brng.toRad();
var lat1 = this.lat().toRad(), lon1 = this.lng().toRad();
var lat2 = Math.asin( Math.sin(lat1)*Math.cos(dist) +
Math.cos(lat1)*Math.sin(dist)*Math.cos(brng) );
var lon2 = lon1 + Math.atan2(Math.sin(brng)*Math.sin(dist)*Math.cos(lat1),
Math.cos(dist)-Math.sin(lat1)*Math.sin(lat2));
if (isNaN(lat2) || isNaN(lon2)) return null;
return new google.maps.LatLng(lat2.toDeg(), lon2.toDeg());
}
google.maps.LatLng.prototype.bearingTo = function(point) {
var lat1 = this.lat().toRad(), lat2 = point.lat().toRad();
var dLon = (point.lng()-this.lng()).toRad();
var y = Math.sin(dLon) * Math.cos(lat2);
var x = Math.cos(lat1)*Math.sin(lat2) -
Math.sin(lat1)*Math.cos(lat2)*Math.cos(dLon);
var brng = Math.atan2(y, x);
return ((brng.toDeg()+360) % 360);
}
google.maps.LatLng.prototype.distanceTo = function(point) {
var lat1 = this.lat().toRad(), lon1 = this.lng().toRad();
var lat2 = point.lat().toRad(), lon2 = point.lng().toRad();
var dLat = lat2 - lat1;
var dLon = lon2 - lon1;
var a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1) * Math.cos(lat2) *
Math.sin(dLon/2) * Math.sin(dLon/2);
return 6371 * (2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)));
}
We would then need to add another loop that renders the intermediate circles inside the for loop that we used previously to render the original circles. Here is how it can be implemented:
var distanceStep = 50; // Render an intermediate circle every 50km.
for (i = 0; i < pathPoints.length; i++) {
new google.maps.Circle({
center: pathPoints[i],
radius: pathPoints[i].radius * 1000,
fillColor: '#FF0000',
fillOpacity: 0.2,
strokeOpacity: 0.5,
strokeWeight: 1,
map: map
});
if (i < (pathPoints.length - 1)) {
distanceToNextPoint = pathPoints[i].distanceTo(pathPoints[i + 1]);
bearingToNextPoint = pathPoints[i].bearingTo(pathPoints[i + 1]);
radius = pathPoints[i].radius;
radiusIncrement = (pathPoints[i + 1].radius - radius) /
(distanceToNextPoint / distanceStep);
for (j = distanceStep;
j < distanceToNextPoint;
j += distanceStep, radius += radiusIncrement) {
new google.maps.Circle({
center: pathPoints[i].destinationPoint(bearingToNextPoint, j),
radius: radius * 1000,
fillColor: '#FF0000',
fillOpacity: 0.2,
strokeWeight: 0,
map: map
});
}
}
}
This is what we would get:
And this is how it would look without the black stroke around the original circles:
Google Maps Cyclones - Figure 3 http://img181.imageshack.us/img181/2908/mp3t.png
As you may notice, the main challenge will be to render the circles with a consistent opacity, even when they overlap on each other. There are a few options to achieve this, but that could be the topic of another question.
In any case, the following is the full implementation for this example:
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8"/>
<title>Google Maps Cyclones</title>
<script src="http://maps.google.com/maps/api/js?sensor=false"
type="text/javascript"></script>
</head>
<body>
<div id="map" style="width: 600px; height: 400px"></div>
<script type="text/javascript">
Number.prototype.toRad = function() {
return this * Math.PI / 180;
}
Number.prototype.toDeg = function() {
return this * 180 / Math.PI;
}
google.maps.LatLng.prototype.destinationPoint = function(brng, dist) {
dist = dist / 6371;
brng = brng.toRad();
var lat1 = this.lat().toRad(), lon1 = this.lng().toRad();
var lat2 = Math.asin( Math.sin(lat1)*Math.cos(dist) +
Math.cos(lat1)*Math.sin(dist)*Math.cos(brng) );
var lon2 = lon1 + Math.atan2(Math.sin(brng)*Math.sin(dist)*Math.cos(lat1),
Math.cos(dist)-Math.sin(lat1)*Math.sin(lat2));
if (isNaN(lat2) || isNaN(lon2)) return null;
return new google.maps.LatLng(lat2.toDeg(), lon2.toDeg());
}
google.maps.LatLng.prototype.bearingTo = function(point) {
var lat1 = this.lat().toRad(), lat2 = point.lat().toRad();
var dLon = (point.lng()-this.lng()).toRad();
var y = Math.sin(dLon) * Math.cos(lat2);
var x = Math.cos(lat1)*Math.sin(lat2) -
Math.sin(lat1)*Math.cos(lat2)*Math.cos(dLon);
var brng = Math.atan2(y, x);
return ((brng.toDeg()+360) % 360);
}
google.maps.LatLng.prototype.distanceTo = function(point) {
var lat1 = this.lat().toRad(), lon1 = this.lng().toRad();
var lat2 = point.lat().toRad(), lon2 = point.lng().toRad();
var dLat = lat2 - lat1;
var dLon = lon2 - lon1;
var a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1) * Math.cos(lat2) *
Math.sin(dLon/2) * Math.sin(dLon/2);
return 6371 * (2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)));
}
var i;
var j;
var distanceToNextPoint;
var bearingToNextPoint;
var radius;
var radiusIncrement;
var distanceStep = 50; // Render an intermediate circle every 50km.
var mapOptions = {
mapTypeId: google.maps.MapTypeId.TERRAIN,
center: new google.maps.LatLng(28.50, -81.50),
zoom: 5
};
var map = new google.maps.Map(document.getElementById("map"), mapOptions);
var pathPoints = [
new google.maps.LatLng(25.48, -71.26),
new google.maps.LatLng(25.38, -73.70),
new google.maps.LatLng(25.28, -77.00),
new google.maps.LatLng(25.24, -80.11),
new google.maps.LatLng(25.94, -82.71),
new google.maps.LatLng(27.70, -87.14)
];
pathPoints[0].radius = 80;
pathPoints[1].radius = 100;
pathPoints[2].radius = 200;
pathPoints[3].radius = 300;
pathPoints[4].radius = 350;
pathPoints[5].radius = 550;
new google.maps.Polyline({
path: pathPoints,
strokeColor: '#00FF00',
strokeOpacity: 1.0,
strokeWeight: 3,
map: map
});
for (i = 0; i < pathPoints.length; i++) {
new google.maps.Circle({
center: pathPoints[i],
radius: pathPoints[i].radius * 1000,
fillColor: '#FF0000',
fillOpacity: 0.2,
strokeOpacity: 0.5,
strokeWeight: 0,
map: map
});
if (i < (pathPoints.length - 1)) {
distanceToNextPoint = pathPoints[i].distanceTo(pathPoints[i + 1]);
bearingToNextPoint = pathPoints[i].bearingTo(pathPoints[i + 1]);
radius = pathPoints[i].radius;
radiusIncrement = (pathPoints[i + 1].radius - radius) /
(distanceToNextPoint / distanceStep);
for (j = distanceStep;
j < distanceToNextPoint;
j += distanceStep, radius += radiusIncrement) {
new google.maps.Circle({
center: pathPoints[i].destinationPoint(bearingToNextPoint, j),
radius: radius * 1000,
fillColor: '#FF0000',
fillOpacity: 0.2,
strokeWeight: 0,
map: map
});
}
}
}
</script>
</body>
</html>
If it's always a string of circles along a line, you could process a pair of adjacent circles at a time, find the two lines that are tangents to both of them and connect them up by their intersections to make one contiguous path. Add some interpolated bezier control points for smoothness.
This might break if your string of circles isn't as neat as the one in your first post (lots of overlap, circles inside circles, etc), but it's a start.

Categories