├── 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 |
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 |
--------------------------------------------------------------------------------