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