├── README.md
├── LICENSE
├── style.css
├── index.html
└── script.js
/README.md:
--------------------------------------------------------------------------------
1 | # scale-a-tron
2 |
3 | A quick-and-dirty map that lets you compare one area to another. Draw a shape around a region, zoom in to another place on the map, and compare with another place by area.
4 |
5 | Try out the live tool at: https://stamen.github.io/scale-a-tron/
6 |
7 | Read more in our blog post: [Introducing Scale-a-Tron](https://hi.stamen.com/introducing-scale-a-tron-91081062e2d0)
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Stamen Design.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/style.css:
--------------------------------------------------------------------------------
1 | html {
2 | height: 100%;
3 | }
4 |
5 | body {
6 | font-family: helvetica, arial, sans-serif;
7 | margin: 0;
8 | height: 100%;
9 | }
10 |
11 | h1 {
12 | margin-top: 0;
13 | }
14 |
15 | #map {
16 | height: 100%;
17 | }
18 |
19 | .debug #map {
20 | height: 33vh;
21 | }
22 |
23 | .mask-canvas {
24 | width: 100%;
25 | height: 33vh;
26 | }
27 |
28 | .mask-canvas,
29 | .clipped-canvas,
30 | .modify-canvas,
31 | .dest-canvas {
32 | display: none;
33 | }
34 |
35 | .debug .mask-canvas,
36 | .debug .clipped-canvas,
37 | .debug .modify-canvas,
38 | .debug .dest-canvas {
39 | display: block;
40 | }
41 |
42 |
43 | .interface {
44 | background: white;
45 | display: flex;
46 | flex-direction: column;
47 | position: absolute;
48 | top: 1rem;
49 | left: 1rem;
50 | border-radius: 5px;
51 | padding: 0.75rem;
52 | z-index: 1000;
53 | max-width: 200px;
54 | }
55 |
56 | .interface button {
57 | appearance: none;
58 | --webkit-appearance: none;
59 | background: white;
60 | border: 1px solid black;
61 | font-size: 1.25rem;
62 | margin-bottom: 1rem;
63 | }
64 |
65 | .input {
66 | display: flex;
67 | flex-direction: column;
68 | }
69 |
70 | .interface > * {
71 | width: 100%;
72 | }
73 |
74 | .explanation {
75 | margin-bottom: 1rem;
76 | }
77 |
78 | .explanation ol {
79 | margin: 0;
80 | padding-left: 1rem;
81 | }
82 |
83 | .credits {
84 | font-size: 0.8em;
85 | margin-top: 2em;
86 | }
87 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | scale-a-tron
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | scale-a-tron
27 |
28 |
29 |
30 | - Draw a polygon
31 | - Click "add to map"
32 | - Pan around and compare the area you selected to other areas
33 |
34 |
35 |
36 |
39 |
42 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | Made with ♥ by
Stamen.
53 | Fork the code on
GitHub.
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
--------------------------------------------------------------------------------
/script.js:
--------------------------------------------------------------------------------
1 | mapboxgl.accessToken = 'pk.eyJ1Ijoic3RhbWVuIiwiYSI6ImNtaW43b3NqOTF6dG0zZXE1YXpyZ2JwdjkifQ.Q27EVtbp2WwXq5uvtBg5Tw';
2 |
3 | var map = new mapboxgl.Map({
4 | container: 'map',
5 | style: 'mapbox://styles/stamen/cknpiguav27ds17tc38fn499t',
6 | // style: 'https://api.maptiler.com/maps/hybrid/style.json?key=BtdqFC6044TX76BVTiPq',
7 | center: [-74, 40.7],
8 | zoom: 10
9 | });
10 |
11 | map.addControl(
12 | new MapboxGeocoder({
13 | accessToken: mapboxgl.accessToken,
14 | mapboxgl: mapboxgl
15 | })
16 | );
17 |
18 | let currentArea;
19 |
20 | let clipPath;
21 | let pointsExtent = [
22 | [+Infinity, +Infinity],
23 | [-Infinity, -Infinity],
24 | ];
25 |
26 | const dpr = window.devicePixelRatio || 1;
27 |
28 | const captureButton = document.querySelector('.capture-button');
29 | const clearButton = document.querySelector('.clear-button');
30 | const rotateButton = document.querySelector('.rotate-button');
31 | const opacitySlider = document.querySelector('.opacity-slider');
32 |
33 | // A canvas that holds the entire map image, everything outside of
34 | // the drawn region is transparent
35 | const maskCanvas = document.querySelector('.mask-canvas');
36 |
37 | // A canvas that holds the masked area but clipped down to a
38 | // smaller area
39 | const clippedCanvas = document.querySelector('.clipped-canvas');
40 |
41 | // A canvas that holds any modifications such as rotation
42 | const modifyCanvas = document.querySelector('.modify-canvas');
43 |
44 | // The destination canvas which is showed on the map
45 | const destCanvas = document.querySelector('.dest-canvas');
46 |
47 | function zoomRatio(lat, zoom) {
48 | return ((156543.03392 * Math.cos((lat * Math.PI) / 180)) / Math.pow(2, zoom));
49 | }
50 |
51 | map.on('move', e => {
52 | const canvasSource = map.getSource('canvas-source');
53 | if (!canvasSource) return;
54 |
55 | let currentMetersPerPx = zoomRatio(map.getCenter().lat, map.getZoom());
56 | let scale = currentArea.metersPerPx / currentMetersPerPx;
57 |
58 | canvasSource.setCoordinates(getCanvasCoordinates(currentArea, scale));
59 | });
60 |
61 | let rotating = false;
62 |
63 | map.on('mousemove', e => {
64 | if (rotating) {
65 | const mouseX = e.originalEvent.layerX;
66 | const mouseY = (e.originalEvent.target.height - e.originalEvent.layerY);
67 | const mapCenter = map.project(map.getCenter());
68 | const dx = mouseX - mapCenter.x;
69 | const dy = mouseY - mapCenter.y;
70 |
71 | const ctx = getContext(modifyCanvas, currentArea.canvasWidth / dpr, currentArea.canvasHeight / dpr, dpr);
72 |
73 | ctx.save();
74 | ctx.translate(currentArea.canvasWidth / 2, currentArea.canvasHeight / 2);
75 | ctx.rotate(-Math.atan2(dy, dx));
76 |
77 |
78 | ctx.drawImage(clippedCanvas,
79 | 0, 0, currentArea.canvasWidth, currentArea.canvasHeight,
80 | -currentArea.canvasWidth / 2, -currentArea.canvasHeight / 2, currentArea.canvasWidth, currentArea.canvasHeight);
81 |
82 | ctx.restore();
83 |
84 | const destCtx = getContext(destCanvas, currentArea.canvasWidth / dpr, currentArea.canvasHeight / dpr, dpr);
85 | destCtx.drawImage(modifyCanvas, 0, 0);
86 | }
87 | })
88 |
89 | map.on('click', e => {
90 | if (rotating) {
91 | rotating = false;
92 | }
93 | })
94 |
95 | const draw = new MapboxDraw({
96 | displayControlsDefault: false,
97 | controls: {
98 | polygon: true,
99 | trash: true
100 | },
101 | defaultMode: 'draw_polygon'
102 | });
103 | map.addControl(draw);
104 |
105 | map.on('draw.create', updateShape);
106 | map.on('draw.update', updateShape);
107 |
108 | function getContext(canvas, width, height, scale = 1) {
109 | canvas.width = width * scale;
110 | canvas.height = height * scale;
111 |
112 | canvas.style.width = width / scale + 'px';
113 | canvas.style.height = height / scale + 'px';
114 |
115 | return canvas.getContext('2d');
116 | }
117 |
118 | function updateShape() {
119 | const shapeCoordinates = draw.getAll().features[0].geometry.coordinates[0];
120 | const shapePoints = shapeCoordinates.map(c => {
121 | const p = map.project(c);
122 |
123 | // Account for pixel density
124 | return {
125 | x: dpr * Math.round(p.x),
126 | y: dpr * Math.round(p.y)
127 | }
128 | });
129 |
130 | pointsExtent = [
131 | [+Infinity, +Infinity],
132 | [-Infinity, -Infinity],
133 | ];
134 |
135 | shapePoints.forEach(({x, y}) => {
136 | if (x < pointsExtent[0][0]) {
137 | pointsExtent[0][0] = x;
138 | }
139 | if (x > pointsExtent[1][0]) {
140 | pointsExtent[1][0] = x;
141 | }
142 | if (y < pointsExtent[0][1]) {
143 | pointsExtent[0][1] = y;
144 | }
145 | if (y > pointsExtent[1][1]) {
146 | pointsExtent[1][1] = y;
147 | }
148 | });
149 |
150 | clipPath = new Path2D();
151 | clipPath.moveTo(shapePoints[0].x, shapePoints[0].y);
152 | shapePoints.slice(1, -1).forEach(p => clipPath.lineTo(p.x, p.y));
153 | clipPath.closePath();
154 |
155 | captureButton.removeAttribute('disabled');
156 | }
157 |
158 | function getCanvasCoordinates(region, scale = 1) {
159 | let { x, y } = map.project(map.getCenter());
160 | let widthOffset = (region.canvasWidth * scale / dpr) / 2;
161 | let heightOffset = (region.canvasHeight * scale / dpr) / 2;
162 |
163 | let points = [
164 | [x - widthOffset, y - heightOffset],
165 | [x + widthOffset, y - heightOffset],
166 | [x + widthOffset, y + heightOffset],
167 | [x - widthOffset, y + heightOffset]
168 | ];
169 |
170 | return points
171 | .map(p => {
172 | const latlng = map.unproject(p);
173 | return [latlng.lng, latlng.lat];
174 | });
175 | }
176 |
177 | /*
178 | * Get map canvas with rendered map on it.
179 | *
180 | * This is made slightly tricky because the canvas is cleared after renders.
181 | */
182 | async function getMapCanvas(map) {
183 | return new Promise((resolve, reject) => {
184 | map.once("render", () => {
185 | resolve(map.getCanvas());
186 | });
187 | map.setBearing(map.getBearing());
188 | });
189 | }
190 |
191 | function clipMapImage(mapCanvas, srcWidth, srcHeight, region) {
192 | const intermediateContext = getContext(maskCanvas, srcWidth, srcHeight, dpr);
193 | const lineWidth = 5;
194 |
195 | intermediateContext.strokeStyle = '#fc03fc';
196 | intermediateContext.lineWidth = lineWidth;
197 | intermediateContext.stroke(region.clipPath);
198 | intermediateContext.clip(region.clipPath);
199 | intermediateContext.drawImage(mapCanvas, 0, 0);
200 |
201 | const clippedCanvasCtx = getContext(clippedCanvas, region.canvasWidth / dpr, region.canvasHeight / dpr, dpr);
202 |
203 | let x = (region.canvasWidth - region.areaWidth) / 2;
204 | let y = (region.canvasHeight - region.areaHeight) / 2;
205 |
206 | clippedCanvasCtx.drawImage(maskCanvas,
207 | pointsExtent[0][0] - lineWidth,
208 | pointsExtent[0][1] - lineWidth,
209 | region.areaWidth * dpr + lineWidth * 2,
210 | region.areaHeight * dpr + lineWidth * 2,
211 | x,
212 | y,
213 | region.areaWidth * dpr + lineWidth * 2,
214 | region.areaHeight * dpr + lineWidth * 2
215 | );
216 |
217 | const destCtx = getContext(destCanvas, region.canvasWidth / dpr, region.canvasHeight / dpr, dpr);
218 | destCtx.drawImage(clippedCanvas, 0, 0);
219 | }
220 |
221 | captureButton.addEventListener('click', () => {
222 | draw.deleteAll();
223 |
224 | captureButton.style.display = 'none';
225 | clearButton.style.display = 'block';
226 | // rotateButton.style.display = 'block';
227 |
228 | // We use a timeout to wait for drawn polygon to be removed
229 | setTimeout(async () => {
230 | const mapboxCanvas = document.querySelector('.mapboxgl-canvas');
231 |
232 | const width = pointsExtent[1][0] - pointsExtent[0][0];
233 | const height = pointsExtent[1][1] - pointsExtent[0][1];
234 | const areaDimension = Math.max(width, height) * 1.5;
235 |
236 | currentArea = {
237 | center: map.getCenter(),
238 | zoom: map.getZoom(),
239 | areaWidth: width,
240 | areaHeight: height,
241 | canvasWidth: areaDimension,
242 | canvasHeight: areaDimension,
243 | metersPerPx: zoomRatio(map.getCenter().lat, map.getZoom()),
244 | clipPath: clipPath
245 | };
246 |
247 | const mapCanvas = await getMapCanvas(map);
248 | clipMapImage(mapCanvas, mapboxCanvas.width, mapboxCanvas.height, currentArea);
249 |
250 | if (!map.getSource('canvas-source')) {
251 | map.addSource('canvas-source', {
252 | type: 'canvas',
253 | canvas: 'dest-canvas',
254 | coordinates: getCanvasCoordinates(currentArea),
255 | animate: true,
256 | });
257 |
258 | map.addLayer({
259 | id: 'canvas-layer',
260 | type: 'raster',
261 | source: 'canvas-source',
262 | paint: {
263 | 'raster-fade-duration': 0
264 | }
265 | });
266 | }
267 | }, 200);
268 | });
269 |
270 | clearButton.addEventListener('click', () => {
271 | captureButton.style.display = 'block';
272 | captureButton.setAttribute('disabled', 'true');
273 | clearButton.style.display = 'none';
274 | rotateButton.style.display = 'none';
275 |
276 | map.removeLayer('canvas-layer');
277 | map.removeSource('canvas-source');
278 | });
279 |
280 | rotateButton.addEventListener('click', () => {
281 | rotating = true;
282 | });
283 |
284 | opacitySlider.addEventListener('change', () => {
285 | map.setPaintProperty('canvas-layer', 'raster-opacity', +opacitySlider.value);
286 | });
287 |
--------------------------------------------------------------------------------