├── .gitignore
├── examples
├── css
│ └── style.css
└── index.html
├── package.json
├── README.md
└── windy.js
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/*
2 |
--------------------------------------------------------------------------------
/examples/css/style.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | background:#000;
3 | width: 100%;
4 | height: 100%;
5 | font: 13px/22px 'Helvetica Neue', Helvetica, sans;
6 | margin: 0;
7 | padding: 0;
8 |
9 | }
10 | #map {
11 | position: absolute;
12 | top: 0; left: 0;
13 | width: 100%;
14 | height: 100%;
15 | }
16 |
17 | canvas {
18 | }
19 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "winds-of-freshy",
3 | "version": "0.0.0",
4 | "description": "animated wind canvas",
5 | "main": "index.js",
6 | "directories": {
7 | "example": "examples",
8 | "test": "test"
9 | },
10 | "scripts": {
11 | "test": "grunt test"
12 | },
13 | "author": "chelm",
14 | "license": "BSD-2-Clause",
15 | "devDependencies": {
16 | "grunt": "0.4.x",
17 | "grunt-mocha-test": "*",
18 | "grunt-contrib-jshint": "0.3.x",
19 | "mocha": "*",
20 | "should": "*"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Winds of Freshy
2 | ------
3 |
4 | A simplified library for interpolating winds vectors and speeds across a grid. Creates a location aware animated surface.
5 |
6 | ### Credit
7 |
8 | * mostly all of this is taken from:
9 | * https://github.com/cambecc/earth
10 | * https://github.com/cambecc/air
11 |
12 |
13 | 1. Load data
14 | 2. create canvas
15 | 3. build an interpolation grid
16 | 4. animate randomized particles on the canvas
17 | - aging particles moves them according to a vector (x, y, magnitude)
18 | 5. color is defined by magnitude (the distance which each particle moves each step)
19 |
--------------------------------------------------------------------------------
/examples/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Windy Demo
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
104 |
105 |
106 |
107 |
--------------------------------------------------------------------------------
/windy.js:
--------------------------------------------------------------------------------
1 |
2 |
3 | /* Global class for simulating wind
4 | // params
5 | url to wind data .json
6 | canvas container el Id
7 |
8 | */
9 |
10 | var Windy = function( params ){
11 | var VELOCITY_SCALE = 1/60000; // scale for wind velocity (completely arbitrary--this value looks nice)
12 | var OVERLAY_ALPHA = Math.floor(0.4*255); // overlay transparency (on scale [0, 255])
13 | var INTENSITY_SCALE_STEP = 10; // step size of particle intensity color scale
14 | var MAX_WIND_INTENSITY = 17; // wind velocity at which particle intensity is maximum (m/s)
15 | var MAX_PARTICLE_AGE = 100; // max number of frames a particle is drawn before regeneration
16 | var PARTICLE_LINE_WIDTH = .5; // line width of a drawn particle
17 | var PARTICLE_MULTIPLIER = 7; // particle count scalar (completely arbitrary--this values looks nice)
18 | var PARTICLE_REDUCTION = 0.75; // reduce particle count to this much of normal for mobile devices
19 | var FRAME_RATE = 30; // desired milliseconds per frame
20 |
21 | var NULL_WIND_VECTOR = [NaN, NaN, null]; // singleton for no wind in the form: [u, v, magnitude]
22 | var TRANSPARENT_BLACK = [255, 0, 0, 0];
23 |
24 |
25 |
26 | var bilinearInterpolateScalar = function(x, y, g00, g10, g01, g11) {
27 | var rx = (1 - x);
28 | var ry = (1 - y);
29 | return g00 * rx * ry + g10 * x * ry + g01 * rx * y + g11 * x * y;
30 | };
31 |
32 | var bilinearInterpolateVector = function(x, y, g00, g10, g01, g11) {
33 | var rx = (1 - x);
34 | var ry = (1 - y);
35 | var a = rx * ry, b = x * ry, c = rx * y, d = x * y;
36 | var u = g00[0] * a + g10[0] * b + g01[0] * c + g11[0] * d;
37 | var v = g00[1] * a + g10[1] * b + g01[1] * c + g11[1] * d;
38 | return [u, v, Math.sqrt(u * u + v * v)];
39 | };
40 |
41 | var createScalarBuilder = function(record) {
42 | var data = record.data;
43 | return {
44 | header: record.header,
45 | //recipe: recipeFor(""),
46 | data: function(i) {
47 | return data[i];
48 | },
49 | interpolate: bilinearInterpolateScalar
50 | }
51 | };
52 |
53 | var createWindBuilder = function(uComp, vComp) {
54 | var uData = uComp.data, vData = vComp.data;
55 | return {
56 | header: uComp.header,
57 | //recipe: recipeFor("wind-" + uComp.header.surface1Value),
58 | data: function(i) {
59 | return [uData[i], vData[i]];
60 | },
61 | interpolate: bilinearInterpolateVector
62 | }
63 | };
64 |
65 | var createBuilder = function(data) {
66 | var uComp = null, vComp = null, scalar = null;
67 |
68 | data.forEach(function(record) {
69 | switch (record.header.parameterCategory + "," + record.header.parameterNumber) {
70 | case "2,2": uComp = record; break;
71 | case "2,3": vComp = record; break;
72 | default:
73 | scalar = record;
74 | }
75 | });
76 |
77 | return uComp ? createWindBuilder(uComp, vComp) : createScalarBuilder(scalar);
78 | };
79 |
80 | var buildGrid = function(data, callback) {
81 | var builder = createBuilder(data);
82 |
83 | var header = builder.header;
84 | var λ0 = header.lo1, φ0 = header.la1; // the grid's origin (e.g., 0.0E, 90.0N)
85 | var Δλ = header.dx, Δφ = header.dy; // distance between grid points (e.g., 2.5 deg lon, 2.5 deg lat)
86 | var ni = header.nx, nj = header.ny; // number of grid points W-E and N-S (e.g., 144 x 73)
87 | var date = new Date(header.refTime);
88 | date.setHours(date.getHours() + header.forecastTime);
89 |
90 | // Scan mode 0 assumed. Longitude increases from λ0, and latitude decreases from φ0.
91 | // http://www.nco.ncep.noaa.gov/pmb/docs/grib2/grib2_table3-4.shtml
92 | var grid = [], p = 0;
93 | var isContinuous = Math.floor(ni * Δλ) >= 360;
94 | for (var j = 0; j < nj; j++) {
95 | var row = [];
96 | for (var i = 0; i < ni; i++, p++) {
97 | row[i] = builder.data(p);
98 | }
99 | if (isContinuous) {
100 | // For wrapped grids, duplicate first column as last column to simplify interpolation logic
101 | row.push(row[0]);
102 | }
103 | grid[j] = row;
104 | }
105 |
106 | function interpolate(λ, φ) {
107 | var i = floorMod(λ - λ0, 360) / Δλ; // calculate longitude index in wrapped range [0, 360)
108 | var j = (φ0 - φ) / Δφ; // calculate latitude index in direction +90 to -90
109 |
110 | var fi = Math.floor(i), ci = fi + 1;
111 | var fj = Math.floor(j), cj = fj + 1;
112 |
113 |
114 | var row;
115 | if ((row = grid[fj])) {
116 | var g00 = row[fi];
117 | var g10 = row[ci];
118 | if (isValue(g00) && isValue(g10) && (row = grid[cj])) {
119 | var g01 = row[fi];
120 | var g11 = row[ci];
121 | if (isValue(g01) && isValue(g11)) {
122 | // All four points found, so interpolate the value.
123 | return builder.interpolate(i - fi, j - fj, g00, g10, g01, g11);
124 | }
125 | }
126 | }
127 | return null;
128 | }
129 |
130 | callback( {
131 | date: date,
132 | //recipe: builder.recipe,
133 | interpolate: interpolate
134 | });
135 | };
136 |
137 |
138 |
139 | /**
140 | * @returns {Boolean} true if the specified value is not null and not undefined.
141 | */
142 | function isValue(x) {
143 | return x !== null && x !== undefined;
144 | }
145 |
146 |
147 | /**
148 | * @returns {Object} the first argument if not null and not undefined, otherwise the second argument.
149 | */
150 | function coalesce(a, b) {
151 | return isValue(a) ? a : b;
152 | }
153 |
154 | /**
155 | * @returns {Number} returns remainder of floored division, i.e., floor(a / n). Useful for consistent modulo
156 | * of negative numbers. See http://en.wikipedia.org/wiki/Modulo_operation.
157 | */
158 | function floorMod(a, n) {
159 | return a - n * Math.floor(a / n);
160 | }
161 |
162 | /**
163 | * @returns {Number} distance between two points having the form [x, y].
164 | */
165 | function distance(a, b) {
166 | var Δx = b[0] - a[0];
167 | var Δy = b[1] - a[1];
168 | return Math.sqrt(Δx * Δx + Δy * Δy);
169 | }
170 |
171 | /**
172 | * @returns {Number} the value x clamped to the range [low, high].
173 | */
174 | function clamp(x, range) {
175 | return Math.max(range[0], Math.min(x, range[1]));
176 | }
177 |
178 | /**
179 | * Pad number with leading zeros. Does not support fractional or negative numbers.
180 | */
181 | function zeroPad(n, width) {
182 | var s = n.toString();
183 | var i = Math.max(width - s.length, 0);
184 | return new Array(i + 1).join("0") + s;
185 | }
186 |
187 | /**
188 | * Calculate distortion of the wind vector caused by the shape of the projection at point (x, y). The wind
189 | * vector is modified in place and returned by this function.
190 | */
191 | function distort(projection, λ, φ, x, y, scale, wind) {
192 | var u = wind[0] * scale;
193 | var v = wind[1] * scale;
194 | var d = distortion(projection, λ, φ, x, y);
195 |
196 | // Scale distortion vectors by u and v, then add.
197 | wind[0] = d[0] * u + d[2] * v;
198 | wind[1] = d[1] * u + d[3] * v;
199 | return wind;
200 | }
201 |
202 | function proportion(i, bounds) {
203 | return (clamp(i, bounds) - bounds[0]) / (bounds[1] - bounds[0]);
204 | }
205 |
206 |
207 | function distortion(projection, λ, φ, x, y) {
208 | var τ = 2 * Math.PI;
209 | var H = Math.pow(10, -5.2);
210 | var hλ = λ < 0 ? H : -H;
211 | var hφ = φ < 0 ? H : -H;
212 | var pλ = project([λ + hλ, φ]);
213 | var pφ = project([λ, φ + hφ]);
214 | //var pλ = projection([λ + hλ, φ]);
215 | //var pφ = projection([λ, φ + hφ]);
216 |
217 | // Meridian scale factor (see Snyder, equation 4-3), where R = 1. This handles issue where length of 1º λ
218 | // changes depending on φ. Without this, there is a pinching effect at the poles.
219 | var k = Math.cos(φ / 360 * τ);
220 | return [
221 | (pλ[0] - x) / hλ / k,
222 | (pλ[1] - y) / hλ / k,
223 | (pφ[0] - x) / hφ,
224 | (pφ[1] - y) / hφ
225 | ];
226 | }
227 |
228 |
229 |
230 | function createField(columns, bounds, callback) {
231 |
232 | /**
233 | * @returns {Array} wind vector [u, v, magnitude] at the point (x, y), or [NaN, NaN, null] if wind
234 | * is undefined at that point.
235 | */
236 | function field(x, y) {
237 | var column = columns[Math.round(x)];
238 | return column && column[Math.round(y)] || NULL_WIND_VECTOR;
239 | }
240 |
241 | // Frees the massive "columns" array for GC. Without this, the array is leaked (in Chrome) each time a new
242 | // field is interpolated because the field closure's context is leaked, for reasons that defy explanation.
243 | field.release = function() {
244 | columns = [];
245 | };
246 |
247 | field.randomize = function(o) { // UNDONE: this method is terrible
248 | var x, y;
249 | var safetyNet = 0;
250 | do {
251 | x = Math.round(Math.floor(Math.random() * bounds.width) + bounds.x);
252 | y = Math.round(Math.floor(Math.random() * bounds.height) + bounds.y)
253 | } while (field(x, y)[2] === null && safetyNet++ < 30);
254 | o.x = x;
255 | o.y = y;
256 | return o;
257 | };
258 |
259 | //field.overlay = mask.imageData;
260 | //return field;
261 | callback( bounds, field );
262 | }
263 |
264 |
265 |
266 | function buildBounds( bounds, width, height ) {
267 | var upperLeft = bounds[0];
268 | var lowerRight = bounds[1];
269 | var x = Math.round(upperLeft[0]); //Math.max(Math.floor(upperLeft[0], 0), 0);
270 | var y = Math.max(Math.floor(upperLeft[1], 0), 0);
271 | var xMax = Math.min(Math.ceil(lowerRight[0], width), width - 1);
272 | var yMax = Math.min(Math.ceil(lowerRight[1], height), height - 1);
273 |
274 | return {x: x, y: y, xMax: width, yMax: yMax, width: width, height: height};
275 | }
276 |
277 | function currentPosition() {
278 | var λ = floorMod(new Date().getTimezoneOffset() / 4, 360); // 24 hours * 60 min / 4 === 360 degrees
279 | return [λ, 0];
280 | }
281 |
282 | var invert = function(x) {
283 | var point = map.pointLocation(new MM.Point(x[1], x[0]));
284 | return [point.lon, point.lat];
285 | };
286 |
287 | var project = function(x) {
288 | var point = map.locationPoint(new MM.Location(x[1], x[0]));
289 | return [point.x, point.y];
290 | };
291 |
292 |
293 | function ensureNumber(num, fallback) {
294 | return _.isFinite(num) || num === Infinity || num === -Infinity ? num : fallback;
295 | }
296 |
297 | function interpolateField( grid, bounds, callback ) {
298 |
299 | var projection = d3.geo.mercator().precision(.1);
300 | var velocityScale = bounds.height * VELOCITY_SCALE;
301 |
302 | var columns = [];
303 | var point = [];
304 | var x = bounds.x;
305 | var scale = { bounds: bounds }; //grid.recipe.scale; //, gradient = scale.gradient;
306 |
307 | function interpolateColumn(x) {
308 | var column = [];
309 | for (var y = bounds.y; y <= bounds.yMax; y += 2) {
310 | point[1] = x; point[0] = y;
311 | var coord = invert(point);
312 | var color = TRANSPARENT_BLACK;
313 | if (coord) {
314 | var λ = coord[0], φ = coord[1];
315 | if (isFinite(λ)) {
316 | var wind = grid.interpolate(λ, φ);
317 | if (wind) {
318 | wind = distort(projection, λ, φ, x, y, velocityScale, wind);
319 | column[y+1] = column[y] = wind;
320 | //color = gradient(proportion(wind[2], scale.bounds), OVERLAY_ALPHA);
321 | }
322 | }
323 | }
324 | //mask.set(x, y, color).set(x+1, y, color).set(x, y+1, color).set(x+1, y+1, color);
325 | }
326 | columns[x+1] = columns[x] = column;
327 | }
328 |
329 | (function batchInterpolate() {
330 | try {
331 | var start = Date.now();
332 | //console.log('start while', x, bounds.width);
333 | while (x < bounds.width) {
334 | interpolateColumn(x);
335 | x += 2;
336 | if ((Date.now() - start) > 400) { //MAX_TASK_TIME) {
337 | // Interpolation is taking too long. Schedule the next batch for later and yield.
338 | //report.progress((x - bounds.x) / (bounds.xMax - bounds.x));
339 | setTimeout(batchInterpolate, 25);
340 | return;
341 | }
342 | }
343 | createField(columns, bounds, callback);
344 | }
345 | catch (e) {
346 | console.log('error in batch interp', e);
347 | }
348 | })();
349 | }
350 |
351 |
352 | var animate = function(bounds, field) {
353 |
354 | function asColorStyle(r, g, b, a) {
355 | return "rgba(" + r + ", " + g + ", " + b + ", " + a + ")";
356 | }
357 |
358 | function windIntensityColorScale(step, maxWind) {
359 | var result = [];
360 | for (var j = 200; j >= 0; j = j - step) {
361 | result.push(asColorStyle(j, j, j, .5));
362 | }
363 | result.indexFor = function(m) { // map wind speed to a style
364 | return Math.floor(Math.min(m, maxWind) / maxWind * (result.length - 1));
365 | };
366 | return result;
367 | }
368 |
369 | var colorStyles = windIntensityColorScale(INTENSITY_SCALE_STEP, MAX_WIND_INTENSITY);
370 | var buckets = colorStyles.map(function() { return []; });
371 | var particleCount = Math.round(bounds.width * PARTICLE_MULTIPLIER);
372 | //if (µ.isMobile()) {
373 | //particleCount *= .75; //PARTICLE_REDUCTION;
374 | //}
375 |
376 | var fadeFillStyle = "rgba(0, 0, 0, 0.97)";
377 |
378 | var particles = [];
379 | for (var i = 0; i < particleCount; i++) {
380 | particles.push(field.randomize({age: Math.floor(Math.random() * MAX_PARTICLE_AGE) + 0}));
381 | }
382 |
383 | function evolve() {
384 | buckets.forEach(function(bucket) { bucket.length = 0; });
385 | particles.forEach(function(particle) {
386 | if (particle.age > MAX_PARTICLE_AGE) {
387 | field.randomize(particle).age = 0;
388 | }
389 | var x = particle.x;
390 | var y = particle.y;
391 | var v = field(x, y); // vector at current position
392 | var m = v[2];
393 | if (m === null) {
394 | particle.age = MAX_PARTICLE_AGE; // particle has escaped the grid, never to return...
395 | }
396 | else {
397 | var xt = x + v[0];
398 | var yt = y + v[1];
399 | if (field(xt, yt)[2] !== null) {
400 | // Path from (x,y) to (xt,yt) is visible, so add this particle to the appropriate draw bucket.
401 | particle.xt = xt;
402 | particle.yt = yt;
403 | buckets[colorStyles.indexFor(m)].push(particle);
404 | }
405 | else {
406 | // Particle isn't visible, but it still moves through the field.
407 | particle.x = xt;
408 | particle.y = yt;
409 | }
410 | }
411 | particle.age += 1;
412 | });
413 | }
414 |
415 | var g = params.canvas.getContext("2d");
416 | g.lineWidth = PARTICLE_LINE_WIDTH;
417 | g.fillStyle = fadeFillStyle;
418 |
419 | function draw() {
420 | // Fade existing particle trails.
421 | var prev = g.globalCompositeOperation;
422 | g.globalCompositeOperation = "destination-in";
423 | g.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
424 | g.globalCompositeOperation = prev;
425 |
426 | // Draw new particle trails.
427 | buckets.forEach(function(bucket, i) {
428 | if (bucket.length > 0) {
429 | g.beginPath();
430 | g.strokeStyle = colorStyles[i];
431 | bucket.forEach(function(particle) {
432 | g.moveTo(particle.x, particle.y);
433 | g.lineTo(particle.xt, particle.yt);
434 | particle.x = particle.xt;
435 | particle.y = particle.yt;
436 | });
437 | g.stroke();
438 | }
439 | });
440 | }
441 |
442 | (function frame() {
443 | try {
444 | evolve();
445 | draw();
446 | setTimeout(frame, FRAME_RATE);
447 | }
448 | catch (e) {
449 | console.error(e);
450 | }
451 | })();
452 | }
453 |
454 | function start( bounds, width, height ){
455 | stop();
456 | // build grid
457 | buildGrid( params.data, function(grid){
458 | // interpolateField
459 | // create Field
460 | interpolateField( grid, buildBounds( bounds, width, height), function( bounds, field ){
461 | // animate the canvas with random points
462 | windy.field = field;
463 | animate( bounds, field );
464 | });
465 | });
466 | }
467 |
468 | function stop(){
469 | if (windy.field) windy.field.release();
470 | }
471 |
472 | var windy = {
473 | params: params,
474 | start: start,
475 | stop: stop
476 | };
477 |
478 | return windy;
479 | }
480 |
481 | //
482 | if ( typeof(module) != "undefined" ){
483 | module.exports = Windy;
484 | }
485 |
--------------------------------------------------------------------------------