Skip to content

Commit

Permalink
feat: Programatically add dots to map
Browse files Browse the repository at this point in the history
Instead of having a hardcoded list of dots in our map, we're now collecting the latitude/longitude information from the information we're already collecting from everyone working at PostHog. We're doing some geocoding to transform that information into lat/long pairs.

There are some problems with the fact that there isn't much uniformity in the way everyone stores their information, so some dots are extremely innacurate.

I've attempted the following pairs and they don't work that well
- Location
- Location, Country
- Location | Country

I ultimately settled with the last one because it looks the most accurate - but there are errors still.

My suggestion: we should suggest everyone to fill their "Location" field with either a "city, country" tuple or simply the long form of their country name, which **should** make the map more accurate.
  • Loading branch information
rafaeelaudibert committed Dec 26, 2024
1 parent e63a256 commit ef6f749
Show file tree
Hide file tree
Showing 6 changed files with 342 additions and 58 deletions.
6 changes: 6 additions & 0 deletions gatsby-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ module.exports = {
apiHost: process.env.GATSBY_SQUEAK_API_HOST,
},
},
{
resolve: `gatsby-mapbox-locations`,
options: {
mapboxToken: process.env.MAPBOX_TOKEN,
},
},
{
resolve: 'gatsby-plugin-mailchimp',
options: {
Expand Down
100 changes: 100 additions & 0 deletions plugins/gatsby-mapbox-locations/gatsby-node.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
const MapboxClient = require('@mapbox/mapbox-sdk')
const { chunk } = require('lodash')

const locationForProfile = (profile) => {
return profile.location ?
{ q: profile.location, types: ['place', 'region', 'country'] } :
{ q: profile.country, types: ['country'] }
}

const sourceNodes = async (options, pluginOptions) => {
const { actions: { createNode }, createNodeId, createContentDigest, getNodes, reporter } = options
const { mapboxToken } = pluginOptions

if (!mapboxToken) {
reporter.panic('You must provide a Mapbox access token')
return
}

// Initialize Mapbox client
const mapboxClient = new MapboxClient({ accessToken: mapboxToken })

// Get all Squeak profiles directly from nodes
// Implement the filter below to guarantee we're not processing profiles that don't have a team

const profiles = getNodes()
.filter(node =>
node.internal.type === 'SqueakProfile' && // For all Squeak profiles
node.teams?.data?.length > 0 && // Implement the following to avoid old profiles: filter: { teams: { data: { elemMatch: { id: { ne: null } } } } }
(node.location || node.country) // Only process profiles with a location or country
)
.map(node => ({
id: node.id,
location: node.location,
country: node.country,
}))

const BATCH_SIZE = 50
const batches = chunk(profiles, BATCH_SIZE)

reporter.info(`Processing ${profiles.length} locations in ${batches.length} batches`)

const locations = []

for (const [index, batch] of batches.entries()) {
reporter.info(`Processing batch ${index + 1}/${batches.length}`)

try {
const response = await mapboxClient.createRequest({
method: 'POST',
path: '/search/geocode/v6/batch',
body: batch.map(locationForProfile)
}).send()

console.log(JSON.stringify(response.body.batch, null, 2))

// Match results with original profiles
response.body.batch.forEach((entry, i) => {
if (entry.features.length > 0) {
const [longitude, latitude] = entry.features[0]?.geometry?.coordinates
if (longitude && latitude) {
locations.push({
profileId: batch[i].id,
location: locationForProfile(batch[i]).q,
coordinates: {
latitude,
longitude
}
})
}
}
})
} catch (error) {
reporter.warn(`Failed to process batch ${index + 1}: ${error.message}`)
}
}

reporter.info(`Successfully processed ${locations.length} locations`)

// Create nodes directly here, not in a separate function
locations.forEach(location => {
const nodeContent = {
...location,
}

const nodeMeta = {
id: createNodeId(`mapbox-location-${location.profileId}`),
parent: null,
children: [],
internal: {
type: `MapboxLocation`,
content: JSON.stringify(nodeContent),
contentDigest: createContentDigest(nodeContent),
},
}

createNode({ ...nodeContent, ...nodeMeta })
})
}

module.exports = { sourceNodes }
9 changes: 9 additions & 0 deletions plugins/gatsby-mapbox-locations/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "gatsby-mapbox-locations",
"version": "1.0.0",
"description": "Gatsby plugin to fetch location coordinates from Mapbox according to a Squeak source",
"main": "gatsby-node.js",
"dependencies": {
"@mapbox/mapbox-sdk": "^0.15.3"
}
}
85 changes: 50 additions & 35 deletions src/components/About/AboutTeam/Map.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,52 @@
import React from 'react'
import { ComposableMap, Geographies, Geography, Marker, Text } from 'react-simple-maps'
import { ComposableMap, Geographies, Geography, Marker } from 'react-simple-maps'
import { useStaticQuery, graphql } from 'gatsby'

const geoUrl = '/world-countries-sans-antarctica.json'
// Avoid displaying two locations that are too close to each other
const isOverlapping = (location: Location, locations: Location[], offset = 2) => {
return locations.some(otherLocation => {
return Math.abs(otherLocation.coordinates.latitude - location.coordinates.latitude) < offset &&
Math.abs(otherLocation.coordinates.longitude - location.coordinates.longitude) < offset
})
}

const GEO_URL = '/world-countries-sans-antarctica.json'
const QUERY = graphql`
query {
allMapboxLocation {
nodes {
location
coordinates {
latitude
longitude
}
}
}
}
`

interface Location {
location: string
coordinates: {
latitude: number
longitude: number
}
}

export default function Map(): JSX.Element {
const data = useStaticQuery(QUERY)

const allLocations: Location[] = data.allMapboxLocation.nodes
const nonOverlappingLocations: Location[] = allLocations.reduce((otherLocations, location) => {
if (isOverlapping(location, otherLocations))
return otherLocations

const locations = [
{ latitude: 41.38848523453815, longitude: 2.166622174480074 }, // barcelona
{ latitude: 50.49696315467801, longitude: 4.784774969058086 }, // belgium
{ latitude: 51.78883703365682, longitude: 0.09233783565354269 }, // london
{ latitude: 45.006964607558096, longitude: -69.08934685858212 }, // maine
{ latitude: 48.138139474816086, longitude: 11.56465867778196 }, // munich
{ latitude: 40.89197279618272, longitude: -73.3075046535768 }, // nyc
{ latitude: 52.953719434919975, longitude: 18.756829547133602 }, // poland
{ latitude: 39.576113891366866, longitude: -119.7908947720339 }, // reno
{ latitude: 37.774485054188894, longitude: -122.38519988449306 }, // san francisco
{ latitude: 37.55577207246805, longitude: -122.29307269491872 }, // san mateo
{ latitude: 27.950692375717704, longitude: -82.46326115746774 }, // tampa
{ latitude: 49.2827291, longitude: -123.1207375 }, // vancouver
{ latitude: 45.5016889, longitude: -73.567256 }, // montreal
{ latitude: 43.653226, longitude: -79.3831843 }, // toronto
{ latitude: 55.8642, longitude: -4.2518 }, // glasgow
{ latitude: 47.6062095, longitude: -122.3320708 }, // seattle
{ latitude: 44.977753, longitude: -93.2650108 }, // minneapolis
{ latitude: 4.570868, longitude: -74.297333 }, // colombia
{ latitude: 47.497912, longitude: 19.040235 }, // budapest
{ latitude: 53.3498053, longitude: -6.2603097 }, // dublin
{ latitude: 39.7392358, longitude: -104.990251 }, // denver
{ latitude: -34.6037, longitude: -58.3816 }, // buenos aires
{ latitude: -19.9167, longitude: -43.9345 }, // belo horizonte
]
return [...otherLocations, location]
}, [] as Location[])

export default function Map() {
return (
<ComposableMap>
<Geographies geography={geoUrl}>
<Geographies geography={GEO_URL}>
{({ geographies }) =>
geographies.map((geo) => (
<Geography
Expand All @@ -48,14 +62,15 @@ export default function Map() {
))
}
</Geographies>
{locations.map(({ longitude, latitude }, index) => {
return (
<Marker key={index} style coordinates={[longitude, latitude]}>
{nonOverlappingLocations.map(({ location, coordinates: { longitude, latitude } }) => (
<Marker key={location} coordinates={[longitude, latitude]}>
<g>
<title>{location}</title>
<circle className="animate-ping" r={7} fill="white" />
<circle r={6} fill="#F54E00" />
</Marker>
)
})}
</g>
</Marker>
))}
</ComposableMap>
)
}
25 changes: 5 additions & 20 deletions src/components/About/AboutTeam/index.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,9 @@
import React from 'react'
import { StaticImage } from 'gatsby-plugin-image'
import { CallToAction } from 'components/CallToAction'
import { Avatar } from './Avatar'
import { graphql, useStaticQuery } from 'gatsby'
import Map from './Map'

interface DotProps {
classes: string
}

const Dot = ({ classes }: DotProps) => {
return (
<div className={`absolute ${classes}`}>
<span className="inline-flex h-4 w-4 mx-auto bg-red rounded-full relative border-[2.5px] border-solid border-white">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red opacity-75"></span>
</span>
</div>
)
}

const avatarStyles = [
{ color: '#DCB1E3', className: 'right-[-30.5rem] top-[-2.5rem]', size: 'lg' },
{ color: '#FDEDC9', className: 'right-[-26rem] top-[-12rem]', size: 'md' },
Expand All @@ -32,13 +17,13 @@ const avatarStyles = [
]

export const AboutTeam = () => {
const { teamMembers, allTeamMembers } = useStaticQuery(query)
const { avatarTeamMembers, teamMembers } = useStaticQuery(query)
const maxTheHedgehog = 1

return (
<section id="team" className="pt-16 pb-12 px-4">
<h3 className="text-5xl mb-4 lg:mb-1 text-center">
We're a team of <span className="text-blue">{allTeamMembers.count - maxTheHedgehog}</span> from all over
We're a team of <span className="text-blue">{teamMembers.count - maxTheHedgehog}</span> from all over
the world.
</h3>
<h4 className="font-semibold opacity-70 text-center">
Expand All @@ -53,7 +38,7 @@ export const AboutTeam = () => {

<div className="relative text-center py-14 md:py-28">
<div className="absolute inset-1/2 scale-[.4] sm:scale-[.6] md:scale-100">
{teamMembers.nodes.map(({ firstName, lastName, country, avatar }, index) => {
{avatarTeamMembers.nodes.map(({ firstName, lastName, country, avatar }, index) => {
const styles = avatarStyles[index]
const name = [firstName, lastName].filter(Boolean).join(' ')
return (
Expand All @@ -79,7 +64,7 @@ export const AboutTeam = () => {

const query = graphql`
{
teamMembers: allSqueakProfile(
avatarTeamMembers: allSqueakProfile(
filter: {
lastName: { in: ["Andra", "Coxon", "Phang", "Obermüller", "Temperton", "Matloka", "Majerik"] }
teams: { data: { elemMatch: { attributes: { name: { ne: null } } } } }
Expand All @@ -95,7 +80,7 @@ const query = graphql`
}
}
}
allTeamMembers: allSqueakProfile(filter: { teams: { data: { elemMatch: { id: { ne: null } } } } }) {
teamMembers: allSqueakProfile(filter: { teams: { data: { elemMatch: { id: { ne: null } } } } }) {
count: totalCount
}
}
Expand Down
Loading

0 comments on commit ef6f749

Please sign in to comment.