├── README.md ├── index.html ├── script.js └── style.css /README.md: -------------------------------------------------------------------------------- 1 | # Hack Club Map 2 | 3 | Map of our global network of clubs. Built with D3. 4 | 5 | [**hackclub.com/map**](https://hackclub.com/map/) 6 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Clubs Map – Hack Club 10 | 11 | 12 | 16 | 20 | 24 | 28 | 29 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | Hack Club flag 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | // Initialization 2 | const width = window.innerWidth 3 | const height = window.innerHeight 4 | const size = [width / 2, height / 2] 5 | 6 | const svg = d3.select('svg').attr('width', width).attr('height', height) 7 | const projection = d3.geoOrthographic().translate(size) 8 | const initialScale = projection.scale() 9 | const path = d3.geoPath().projection(projection) 10 | let locations = [] 11 | 12 | // Draw layers 13 | const oceanFill = svg 14 | .append('defs') 15 | .append('radialGradient') 16 | .attr('id', 'ocean-fill') 17 | .attr('cx', '75%') 18 | .attr('cy', '25%') 19 | oceanFill.append('stop').attr('offset', '10%').attr('stop-color', '#5bc0de') 20 | oceanFill.append('stop').attr('offset', '100%').attr('stop-color', '#338eda') 21 | svg 22 | .append('circle') 23 | .attr('class', 'layer') 24 | .attr('cx', size[0]) 25 | .attr('cy', size[1]) 26 | .attr('r', initialScale) 27 | .style('fill', 'url(#ocean-fill)') 28 | 29 | const featureGroup = svg.append('g').attr('id', 'features') 30 | const render = () => svg.selectAll('.segment').attr('d', path) 31 | 32 | const globeHighlight = svg 33 | .append('defs') 34 | .append('radialGradient') 35 | .attr('id', 'globe-highlight') 36 | .attr('cx', '75%') 37 | .attr('cy', '25%') 38 | globeHighlight 39 | .append('stop') 40 | .attr('offset', '5%') 41 | .attr('stop-color', '#dff') 42 | .attr('stop-opacity', '0.5') 43 | globeHighlight 44 | .append('stop') 45 | .attr('offset', '100%') 46 | .attr('stop-color', '#9ab') 47 | .attr('stop-opacity', '0.0625') 48 | svg 49 | .append('circle') 50 | .attr('class', 'layer') 51 | .attr('cx', size[0]) 52 | .attr('cy', size[1]) 53 | .attr('r', initialScale) 54 | .style('fill', 'url(#globe-highlight)') 55 | 56 | const globeShading = svg 57 | .append('defs') 58 | .append('radialGradient') 59 | .attr('id', 'globe-shading') 60 | .attr('cx', '50%') 61 | .attr('cy', '40%') 62 | globeShading 63 | .append('stop') 64 | .attr('offset', '50%') 65 | .attr('stop-color', '#9ab') 66 | .attr('stop-opacity', '0') 67 | globeShading 68 | .append('stop') 69 | .attr('offset', '100%') 70 | .attr('stop-color', '#3e6184') 71 | .attr('stop-opacity', '0.3') 72 | svg 73 | .append('circle') 74 | .attr('class', 'layer') 75 | .attr('cx', size[0]) 76 | .attr('cy', size[1]) 77 | .attr('r', initialScale) 78 | .style('fill', 'url(#globe-shading)') 79 | 80 | const markerGroup = svg.append('g').attr('id', 'markers') 81 | 82 | // Rotation + interactivity 83 | const rotationDelay = 2000 84 | const autorotate = d3.timer(rotate) 85 | let lastTime = d3.now() 86 | function degPerMs() { 87 | // rotate faster when zoomed out, 88 | // rotate slower when zoomed in 89 | return 3 / projection.scale() 90 | } 91 | let rotate0, coords0 92 | 93 | function startRotation() { 94 | autorotate.restart(rotate, rotationDelay) 95 | } 96 | 97 | function stopRotation() { 98 | autorotate.stop() 99 | } 100 | 101 | function rotate(elapsed) { 102 | now = d3.now() 103 | diff = now - lastTime 104 | if (diff < elapsed) { 105 | rotation = projection.rotate() 106 | rotation[0] += (diff % 60) * degPerMs() 107 | projection.rotate(rotation) 108 | render() 109 | drawMarkers() 110 | } else { 111 | // this only needs to run once 112 | d3.select('#banner').style('opacity', 0) 113 | } 114 | lastTime = now 115 | } 116 | 117 | const coords = () => projection.rotate(rotate0).invert([d3.event.x, d3.event.y]) 118 | 119 | svg 120 | .call( 121 | d3 122 | .drag() 123 | .on('start', () => { 124 | rotate0 = projection.rotate() 125 | coords0 = coords() 126 | }) 127 | .on('drag', () => { 128 | stopRotation() 129 | const coords1 = coords() 130 | projection.rotate([ 131 | rotate0[0] + coords1[0] - coords0[0], 132 | rotate0[1] + coords1[1] - coords0[1], 133 | ]) 134 | render() 135 | drawMarkers() 136 | }) 137 | .on('end', () => { 138 | lastTime = d3.now() 139 | startRotation() 140 | }) 141 | .filter(() => !(d3.event.touches && d3.event.touches.length === 2)) 142 | ) 143 | .call( 144 | d3.zoom().on('zoom', () => { 145 | const newScale = initialScale * d3.event.transform.k 146 | projection.scale(newScale) 147 | d3.selectAll('.layer').attr('r', newScale) 148 | render() 149 | drawMarkers() 150 | }) 151 | ) 152 | 153 | // Import geographies 154 | d3.json( 155 | 'https://gist.githubusercontent.com/mbostock/4090846/raw/d534aba169207548a8a3d670c9c2cc719ff05c47/world-110m.json' 156 | ).then((worldData) => { 157 | featureGroup 158 | .selectAll('.segment') 159 | .data(topojson.feature(worldData, worldData.objects.countries).features) 160 | .enter() 161 | .append('path') 162 | .attr('class', 'segment') 163 | .attr('d', path) 164 | }) 165 | 166 | d3.json( 167 | 'https://api2.hackclub.com/v0/Operations/Clubs/?select=%7B%22fields%22:%5B%22Name%22,%22Latitude%22,%22Longitude%22,%22Customized%20Name%22%5D,%22filterByFormula%22:%22AND(%7BRejected%7D=0,%7BDummy%7D=0,%7BDropped%7D=0)%22%7D' 168 | ) 169 | .then((data) => _.filter(data, (c) => !_.isEmpty(c.fields['Latitude']))) 170 | .then((data) => _.filter(data, (c) => c.fields['Latitude'] != 0)) 171 | .then((clubs) => { 172 | console.log(clubs.length) 173 | clubs.forEach(({ fields }) => { 174 | locations.push({ 175 | name: fields['Name'], 176 | lat: fields['Latitude'][0], 177 | lng: fields['Longitude'][0], 178 | }) 179 | }) 180 | return locations 181 | }) 182 | .then(() => { 183 | drawMarkers() 184 | }) 185 | 186 | // Draw club markers 187 | function drawMarkers() { 188 | const markers = markerGroup.selectAll('circle').data(locations) 189 | markers 190 | .enter() 191 | .append('circle') 192 | .merge(markers) 193 | .attr('r', 6) 194 | .attr('cx', ({ lng, lat }) => projection([lng, lat])[0]) 195 | .attr('cy', ({ lng, lat }) => projection([lng, lat])[1]) 196 | .attr('fill', ({ lng, lat }) => { 197 | gdistance = d3.geoDistance([lng, lat], projection.invert(size)) 198 | return gdistance > 1.625 ? 'none' : '#ec3750' 199 | }) 200 | .on('mouseenter', (club) => { 201 | d3.select('#banner').text(club.name).style('opacity', 1) 202 | }) 203 | .on('click', (club) => { 204 | d3.select('#banner').text(club.name).style('opacity', 1) 205 | }) 206 | svg.append(markers) 207 | } 208 | 209 | drawMarkers() 210 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | /* 2 | font-face { 3 | font-family: 'Phantom Sans'; 4 | src: url('https://hackclub.com/fonts/Phantom_Sans_0.6/Regular.woff') 5 | format('woff'), 6 | url('https://hackclub.com/fonts/Phantom_Sans_0.6/Regular.woff2') 7 | format('woff2'); 8 | font-weight: normal; 9 | font-style: normal; 10 | font-display: swap; 11 | } 12 | */ 13 | @font-face { 14 | font-family: 'Phantom Sans'; 15 | src: url('https://hackclub.com/fonts/Phantom_Sans_0.6/Bold.woff') 16 | format('woff'), 17 | url('https://hackclub.com/fonts/Phantom_Sans_0.6/Bold.woff2') 18 | format('woff2'); 19 | font-weight: bold; 20 | font-style: normal; 21 | font-display: swap; 22 | } 23 | 24 | body { 25 | margin: 0; 26 | background-color: #252429; 27 | font-family: "Phantom Sans", system-ui, Roboto, sans-serif; 28 | overflow: hidden; 29 | } 30 | 31 | svg { 32 | cursor: move; 33 | } 34 | 35 | #banner { 36 | background-color: #ec3750; 37 | color: #fff; 38 | pointer-events: none; 39 | padding: 1rem 1.5rem; 40 | border-radius: 999px; 41 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.25); 42 | max-width: calc(100% - 1.5rem); 43 | text-align: center; 44 | position: absolute; 45 | top: 1rem; 46 | left: 50%; 47 | transform: translateX(-50%); 48 | opacity: 0; 49 | transition: opacity .25s ease-out; 50 | } 51 | 52 | @media (max-width: 32em) { 53 | #banner { 54 | top: auto; 55 | bottom: 1rem; 56 | } 57 | } 58 | 59 | .segment { 60 | stroke: #f9fafc; 61 | stroke-width: 1px; 62 | fill: #e0e6ed; 63 | } 64 | 65 | .layer { 66 | pointer-events: none; 67 | } 68 | 69 | #markers circle { 70 | opacity: 0.75; 71 | } 72 | --------------------------------------------------------------------------------