Building in public: Remote Team Maps

🤔 Problem

Companies on RemoteHub can add their remote team locations on a map, but this comes only with a company profile which is a paid feature.

More and more companies are going remote and hiring their first remote team members from other cities.

I want them to be able to build a simple map for free. And I hope for some of them this will also lead to a paid account later.

💡 Solution

I will build a standalone map builder where teams can put their locations on a map. I'll also make it easy for them to share their map on their social media / website etc.

HTML mockup

I started with a simple HTML mockup with a map and textfield for adding cities. I've used Mapbox before and as it's familiar to me, I'll use it this time too.

Searching cities

So I need a way to search cities by name. Probably the best way for this is to use Google Maps Places API.

I start by including the library in my <head> section:


<script src="https://maps.googleapis.com/maps/api/js?key=AIzaSyA-GRcbemml4SshZ-hm9j9juGnD7ApXzjk&libraries=places&callback=initAutocomplete" defer></script>
            

This has a "callback" parameter that calls for initAutocomplete() function when it's done loading:


var autocomplete;
function initAutocomplete() {
    autocomplete = new google.maps.places.Autocomplete(
        document.getElementById('autocomplete'),
        {types: ['geocode']}
    );

    autocomplete.addListener('place_changed', fillInAddress);
}
            

When loaded, it will build a Google Maps Autocomplete into a textfield with id="autocomplete".

When starting to type a city name, Google will suggest locations like this:

initAutocomplete() added a event listener that will call fillInAddress() when a place is chosen from the Google dropdown.

I want this fillInAddress() to fill in some hidden fields in my HTML so I could use them later when adding a city:


<div class="mapbuilder__top__inside">
    <input type="hidden" name="city_name" id="locality">
    <input type="hidden" name="city_administrative" id="administrative_area_level_1">
    <input type="hidden" name="city_country_code" id="country_code">
    <input type="hidden" name="city_country" id="country">
    <input type="hidden" name="city_lat" id="lat">
    <input type="hidden" name="city_lng" id="lng">

    <input type="text" class="mapbuilder__input mr-10"
            placeholder="Add a city..."
            id="autocomplete"
            onkeypress="return event.keyCode != 13"
            autocomplete="off">
    <button type="button" class="button button--border"
            onclick="addCity()">Add</button>
</div>
            

The following code is taken from the Google Maps Places Autocomplete docs (there's a detailed description about how it works):


var componentForm = {
    locality: 'long_name',
    administrative_area_level_1: 'long_name',
    country: 'long_name'
};

function fillInAddress() {
    var place = autocomplete.getPlace();

    for (var component in componentForm) {
        document.getElementById(component).value = '';
        document.getElementById(component).disabled = false;
    }

    for (var i = 0; i < place.address_components.length; i++) {
        var addressType = place.address_components[i].types[0];

        for (var ii = 0; ii < place.address_components[i].types.length; ii++) {

            for (var component in componentForm) {
                if (component == place.address_components[i].types[ii]) {
                    var val = place.address_components[i][componentForm[place.address_components[i].types[ii]]];
                    document.getElementById(component).value = val;

                    if (componentForm['country']) {
                        var val = place.address_components[i]['short_name'];
                        document.getElementById('country_code').value = val;
                    }
                }
            }
        }
    }

    document.getElementById('lat').value = place.geometry.location.lat();
    document.getElementById('lng').value = place.geometry.location.lng();
}
            

And it works! When choosing a city, it will fill in my hidden fields:

Preparing the database

I will need to save these maps to my database so I could later show them on RemoteHub. And also let their builders edit them later.

I created two tables to my PostgreSQL database:

1. "maps" will hold all maps with their data like company name, logo etc

2. "map_cities" stores cities for each map

Map Builder is located at https://remotehub.io/mapbuilder/new

But I now wrote a function that first creates a new map and then redirects to a personal map URL with hash like this: https://remotehub.io/mapbuilder/ybfqcowk7h


public function new()
{
    $hash = strtolower(str_random(10));

    $map = new Map;
    $map->hash = $hash;
    $map->save();

    return redirect()->route('mapbuilder.build', [
        'hash' => $hash
    ]);
}
            

This way the person who starts building map can always return to the URL to edit it.

Adding cities

Now I have my HTML mockup with Google Maps autocomplete to search cities from. I also have my database prepared.

So let's start adding cities to the map!

First, I'll write two backend API methods for adding and removing cities:


POST /api/mapbuilder/{hash}/cities
POST /api/mapbuilder/{hash}/cities/{map_city_id}/delete
            

... and then I will call them from JavaScript:


function addCity()
{
    var xmlhttp = new XMLHttpRequest();

    xmlhttp.onreadystatechange = function() {
        if (this.readyState == 4 && this.status == 200) {
            var res = JSON.parse(this.responseText);

            addMarker(res.city, res.map_city);

            document.getElementById('cities-count').innerHTML = res.cities_count;
            document.getElementById('countries-count').innerHTML = res.countries_count;
            document.getElementById('timezones-count').innerHTML = res.timezones_count;
        }
    };

    xmlhttp.open("POST", '/api/mapbuilder/{{ $map->hash }}/cities?city=' + document.getElementById('locality').value, true);
    xmlhttp.send();
}
            

This addCity() function makes a HTTP POST request to my backend API and gets some data back to update the page (like city photo, cities/countries/timezones count etc).

And when this HTTP call is done, it will continue to addMarker() that will then put the marker on Mapbox:


function addMarker(city, map_city) {
    var el = document.createElement('div');
    el.setAttribute('id', 'mapCity' + map_city.id);
    el.className = 'map__marker map__marker--trashable';
    el.style.backgroundImage = 'url(https://remotehub.io/storage/cities/screens/' + city.photo + ')';
    el.style.width = '40px';
    el.style.height = '40px';
    el.dataset.mapCityId = map_city.id;

    var el2 = document.createElement('div');
    el2.className = 'map__marker__x';
    el.appendChild(el2);

    el.addEventListener('click', function() {
        if (confirm('Remove?')) {
            var xmlhttp = new XMLHttpRequest();

            xmlhttp.onreadystatechange = function() {
                if (this.readyState == 4 && this.status == 204) {
                    document.getElementById('mapCity' + el.dataset.mapCityId).remove();
                }
            };

            xmlhttp.open("POST", '/api/mapbuilder/{{ $map->hash }}/cities/' + el.dataset.mapCityId + '/delete', true);
            xmlhttp.send();
        }
    });

    var newMarker = new mapboxgl.Marker(el)
        .setLngLat([document.getElementById('lng').value, document.getElementById('lat').value])
        .addTo(map);

        bounds.extend([document.getElementById('lng').value, document.getElementById('lat').value]);
        map.fitBounds(bounds, {padding: 50});

        document.getElementById('lat').value = "";
        document.getElementById('lng').value = "";
        document.getElementById('autocomplete').value = "";
}
            

Works great! And see how it makes sure all markers are always visible on the map. I do this with the map.fitBounds() function (see above).

Okay. What else? I can add markers, but when I refresh the page I loose them. I already have them in my map_cities table thanks to the AJAX call, but I also need to render them on a map when page is loaded.

For this, I build a FeatureCollection JSON like this:


var geojson = {
"type": "FeatureCollection",
"features": [
    @foreach($map_cities as $map_city)
    @if($map_city->city->lat && $map_city->city->lng)
    {
        "type": "Feature",
        "properties": {
            "iconUrl": "{{ $map_city->city->photo ? asset('storage/cities/screens/'.$map_city->city->photo) : asset('images/emoji/city.png') }}",
            "iconSize": [40, 40],
            "slug": "{{ $map_city->city->slug }}",
            "mapCityId": {{ $map_city->id }}
        },
        "geometry": {
            "type": "Point",
            "coordinates": [
                {{ $map_city->city->lng }},
                {{ $map_city->city->lat }}
            ]
        }
    },
    @endif
    @endforeach
]
};
    

And render them on the map:


geojson.features.forEach(function(marker) {
    var el = document.createElement('div');
    el.setAttribute('id', 'mapCity' + marker.properties.mapCityId);
    el.className = 'map__marker map__marker--trashable';
    el.style.backgroundImage = 'url(' + marker.properties.iconUrl + ')';
    el.style.width = marker.properties.iconSize[0] + 'px';
    el.style.height = marker.properties.iconSize[1] + 'px';
    el.dataset.mapCityId = marker.properties.mapCityId;

    var el2 = document.createElement('div');
    el2.className = 'map__marker__x';
    el.appendChild(el2);

    el.addEventListener('click', function() {
        if (confirm('Remove?')) {
            var xmlhttp = new XMLHttpRequest();

            xmlhttp.onreadystatechange = function() {
                if (this.readyState == 4 && this.status == 204) {
                    document.getElementById('mapCity' + el.dataset.mapCityId).remove();
                }
            };

            xmlhttp.open("POST", '/api/mapbuilder/{{ $map->hash }}/cities/' + el.dataset.mapCityId + '/delete', true);
            xmlhttp.send();
        }
    });

    new mapboxgl.Marker(el)
        .setLngLat(marker.geometry.coordinates)
        .addTo(map);

    bounds.extend(marker.geometry.coordinates);
});

if (geojson.features.length > 0) {
    map.fitBounds(bounds, {padding: 50});
}
            

Company name and logo

As you can see from the mockup, I also let companies to add their name and logo. Let's store them in a database now.

I don't want to use the default file input as it doesn't fit with the design. So I designed the <div> instead and put a file input on top of it with opacity: 0 (invisible!).

Now when you click on the invisible file field, you'll get the normal browser file dialog to choose your logo.

This function will detect when user selects a file and then writes the image to the logo <div> as a background:


document.getElementById('choose-logo').addEventListener("change", function(e){
    var input = this;

    if (input.files && input.files[0]) {
        var reader = new FileReader();

        reader.onload = function(e) {
            document.getElementById('logo').style.backgroundImage = "url('" + e.target.result + "')";
        }

        reader.readAsDataURL(input.files[0]); // convert to base64 string
    }
});
            

Which looks like this – I think it's much better than a default file field without a preview! And actually super simple to make!

How to store the name and logo? I could do an AJAX call here, but I think it's totally find to just submit this when user clicks "Save & Publish" button. It's easier, but I think it's as much convenient for the user as the AJAX call would be.

As I need a few more details before publishing the map, I made this little popup that asks for email and website, and I'll store team name and logo together with this.

Generating a map image for sharing

I now have everything to edit and render the map, but I'll also want to render the map as an image file that can be shared.

For this, I need a unique URL for every map that has a map image attached to it with og:image <meta> tag.

Easy! First, I'll set up a separate page with the map that has the right dimensions (I'll use 1200x628px)

I will then use spatie/browsershow to take a screenshot of it and store it as an image file.


$file = strtolower(str_random(10)).'.png';
$url = env('APP_URL').'/maps/new/'.$hash.'/render';

Browsershot::url($url)
    ->setDelay(2000)
    ->deviceScaleFactor(2)
    ->select('#render')
    ->save(storage_path().'/app/public/mapbuilder/maps/'.$file);
            

And then I'll show it on a nice little page for the user to share it:

When I try these share buttons, it will nicely render the map as a social card image:

Listing maps that have been made

It would be nice to see maps that others have built. So I built this: https://remotehub.io/mapbuilder

This is also the first page where people land when the come to the Map Builder. So they can first see what others have built and then start creating their own map.

To be continued... come back later for updates!

Hi, I'm Rauno Metsa - a solo founder bootstrapping micro-startups. You can learn more about me on my website or follow my work on Twitter.