Статьи

Создавайте глобальные визуализации осадков (дождя) с помощью HTML5, Canvas и открытых данных.

В настоящее время я работаю над своей следующей книгой для Three.js, и одна из глав посвящена визуализации открытых данных. Когда я искал данные, которые мог бы использовать, я наткнулся на набор данных из NOAA. Через этот сайт вы можете скачать набор ежемесячных отчетов об осадках для всего мира в сеточном формате. Поэтому я скачал их и начал играть с данными, чтобы посмотреть, как они выглядят и как их можно использовать. В этой статье я не собираюсь показывать вам результат на основе three.js, но я дам вам краткий обзор того, как добраться до формата, который я изначально использовал для целей отладки:

2012-07-перхлорэтилен

На этом изображении вы можете увидеть месячные осадки для всего мира в логарифмическом масштабе за июль 2012 года. Я также создал простой сайт, который показывает это и анимацию в действии.

Итак, что вам нужно сделать, чтобы конвертировать набор, который вы можете загрузить с сайта NOAA, во что-то визуальное.

  • Загрузите и конвертируйте формат NetCDF.
  • Загрузите полученный CSV-файл
  • Обработка данных CSV в мировой сетке
  • Анимировать переходы между месяцами
  • В качестве бонуса: также создайте легенду, чтобы показать, что цвет означает, что

Однако сначала нам нужно получить данные.

Скачать и конвертировать формат NetCDF

Первое, что нам нужно сделать, это получить данные. Я использовал следующую ссылку: где вы можете определить диапазон данных, которые вы хотите загрузить. В этом примере я использовал диапазон с января 2012 года по декабрь 2012 года и выбрал опцию для создания подмножества без построения графика.

Однако формат, в котором он загружается, не может напрямую использоваться в качестве входных данных для нашей визуализации на основе холста HTML5. Вы можете использовать ncdump-json для создания файла JSON, но вам все еще нужно уметь его интерпретировать, поэтому я выбрал альтернативный способ. Я только что написал простую Java-программу для преобразования формата NetCDF в простой CSV-файл.

Я использовал следующие maven-зависимости:

01
02
03
04
05
06
07
08
09
10
11
12
13
<dependencies>
        <dependency>
            <groupId>edu.ucar</groupId>
            <artifactId>netcdf</artifactId>
            <version>4.2.20</version>
        </dependency>
 
        <dependency>
            <groupId>commons-io</groupId>
            <artifactId>commons-io</artifactId>
            <version>2.4</version>
        </dependency>
    </dependencies>

И используйте следующий фрагмент кода Java:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
public class NetCDFDump {
 
    public static void main(String[] args) throws IOException, InvalidRangeException {
 
        String year = "2012";
        NetcdfFile nc = NetcdfFile.open("src/main/resources/X84.31.143.145.44.1.47.49.nc");
 
        Variable precip = nc.findVariable("precip");
 
        // use the shapes to create an array
        int[] shapes = precip.getShape();
        // month, lat, lon
        float[][][] data = new float[shapes[0]][shapes[1]][shapes[2]];
 
        // iterate over 12 (or 11) months
        int[] pos = new int[3];
        int[] shape = {1,1,1};
        for (int i = 0 ; i < shapes[0] ; i++) {
            pos[0]=i;
            for (int lat = 0 ; lat < shapes[1]; lat++) {
                pos[1] = lat;
                for (int lon = 0 ; lon < shapes[2]; lon++) {
                    pos[2] = lon;
                    Array result = precip.read(pos, shape);
                    data[pos[0]][pos[1]][pos[2]] = result.getFloat(0);
                }
            }
        }
 
        // output data like this
        // month, lat, lon, humidity
        float[][] combined = new float[data[0].length][data[0][0].length];
 
        for (int m = 0 ; m < data.length ; m++) {
            File outputM = new File(year + "-out-" + m + ".csv");
            for (int lat = 0 ; lat < data[m].length ; lat++) {
                for (int lon = 0 ; lon < data[m][lat].length; lon++) {
                    float value = data[m][lat][lon];
                    if (value > -1000) {
                        combined[lat][lon]+=value;
                    } else {
                        combined[lat][lon]+=-1000;
                    }
 
                    // write the string for outputfile
                    StringBuffer bOut = new StringBuffer();
                    bOut.append(m);
                    bOut.append(',');
                    bOut.append(lat);
                    bOut.append(',');
                    bOut.append(lon);
                    bOut.append(',');
                    bOut.append(value);
                    bOut.append('\n');
 
                    // write to month file
                    FileUtils.write(outputM,bOut,true);
                }
            }
        }
 
        // now process the combined
        File outputM = new File(year + "-gem.csv");
        for (int i = 0; i < combined.length; i++) {
            for (int j = 0; j < combined[0].length; j++) {
                StringBuffer bOut = new StringBuffer();
                bOut.append(i);
                bOut.append(',');
                bOut.append(j);
                bOut.append(',');
                bOut.append(combined[i][j]/data.length);
                bOut.append('\n');
 
                FileUtils.write(outputM, bOut, true);
            }
        }
    }
}

Я не буду вдаваться в подробности того, что происходит, но этот фрагмент кода приводит к нескольким файлам, по одному на каждый месяц и один, содержащий среднее значение.

Каждый месяц отображается в следующем формате

1
2
3
4
5
6
7
...
0,65,78,32.65
0,65,79,35.09
0,65,80,31.14
0,65,81,42.7
0,65,82,49.57
...

Соответственно значения означают: месяц, широту, долготу и количество осадков. Для среднего это выглядит почти так же, за исключением того, что первая запись опущена.

1
2
3
4
5
...
59,94,59.874165
59,95,65.954994
59,96,57.805836
...

Теперь, когда у нас есть данные в удобном для использования формате, мы можем использовать их для создания визуализаций.

Загрузите полученный CSV-файл

Чтобы загрузить файл, мы просто используем простой XMLHttpRequest, например:

01
02
03
04
05
06
07
08
09
10
11
12
13
// create an XMLHttpRequest to get the data
       var xmlhttp = new XMLHttpRequest();
        xmlhttp.onreadystatechange = function() {
            if (xmlhttp.readyState == 4 && xmlhttp.status == 200) {
                var coords = CSVToArray(xmlhttp.responseText,",");
                // and process each of the coordinates
                ...
            }
        }
 
        // make the call and use the callback to process the result
        xmlhttp.open("GET", "location/of/the/file", true);
        xmlhttp.send();

Переменная ords теперь содержит все координаты, а для каждой координаты — значение, которое нужно показать. Преобразовать это в холст на самом деле очень легко.

Обработка данных CSV в мировой сетке

В обратном вызове из XMLHttpRequest мы проверяем, получили ли мы данные, и конвертируем их в набор координат. Единственное, что нам нужно сделать, это преобразовать эти координаты в визуализацию на холсте.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
var coords = CSVToArray(xmlhttp.responseText,",");
                coords.forEach(function(point) {
 
                    var offset = 0;
                    if (point.length > 3) {
                        offset = 1;
                    }
 
                    if (parseFloat(point[2+offset]) >= 0) {
                        var lat = parseInt(point[0+offset]);
                        var lon = parseInt(point[1+offset]);
                        var value = parseFloat(point[2+offset]);
 
                        if (value > max) max = value;
 
                        // lat is from 0 to 180
                        // lon is from 0 to 360
                        var x = canvas.width/360*((lon)-180);
                        if (x<=0) {
                            x=canvas.width-(x*-1);
                        }
                        var y = canvas.height/180*lat;
 
                        if (value >= 0) {
                            context.beginPath();
                            context.rect(x,y,4,4);
                            context.fillStyle = scale(value).hex();
                            context.fill();
 
                        }
                    }
                });

Как видите, очень простой код, в котором мы просто занимаем позиции, конвертируем их в координаты X и Y на холсте и создаем небольшой квадрат определенного цвета. Для генерации цвета мы используем шкалу Chroma.js .

1
var scale = chroma.scale(['red' , 'yellow', 'green', 'blue']).domain([1,1700], 100, 'log');

Этот вызов создает цветовую шкалу от красного до желтого, от зеленого до синего. Значения находятся в диапазоне от 1 до 1700, делятся на 100 шагов и используют логарифмическую шкалу. Это приводит к следующему изображению (на этот раз для осадков в январе 2012 года:

2012-01-перхлорэтилен

Поскольку у нас есть цифры за все месяцы, теперь мы можем легко создать простую анимацию.

Анимировать переходы между месяцами

Для анимации мы собираемся создать что-то вроде показанного в следующем фильме, где мы медленно переключаемся между различными месяцами:

Создание этой анимации может быть сделано довольно просто, просто показывая изображения друг над другом и изменяя непрозрачность. Поэтому сначала настройте несколько CSS, которые скрывают большинство изображений и размещают их все друг на друге.

01
02
03
04
05
06
07
08
09
10
11
#cf {
            position:relative;
            margin:0 auto;
            height: 700px;
        }
 
        #cf img {
            position:absolute;
            left:0;
            width: 1600px;
        }

Теперь мы можем просто добавить изображения и использовать класс ‘bottom’, чтобы показать только первое изображение:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
<div id="cf">
    <img id="img-1" class="top" src="./assets/images/2012-01-perc.png" />
    <img id="img-2" class="bottom" src="./assets/images/2012-02-perc.png" />
    <img id="img-3" class="bottom" src="./assets/images/2012-03-perc.png" />
    <img id="img-4" class="bottom" src="./assets/images/2012-04-perc.png" />
    <img id="img-5" class="bottom" src="./assets/images/2012-05-perc.png" />
    <img id="img-6" class="bottom" src="./assets/images/2012-06-perc.png" />
    <img id="img-7" class="bottom" src="./assets/images/2012-07-perc.png" />
    <img id="img-8" class="bottom" src="./assets/images/2012-08-perc.png" />
    <img id="img-9" class="bottom" src="./assets/images/2012-09-perc.png" />
    <img id="img-10" class="bottom" src="./assets/images/2012-10-perc.png" />
    <img id="img-11" class="bottom" src="./assets/images/2012-11-perc.png" />
    <img id="img-12" class="bottom" src="./assets/images/2012-12-perc.png" />
</div>

Теперь нам просто нужен JavaScript, чтобы связать все вместе:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
var month=[];
    month[0]="January";
    month[1]="February";
    month[2]="March";
    month[3]="April";
    month[4]="May";
    month[5]="June";
    month[6]="July";
    month[7]="August";
    month[8]="September";
    month[9]="October";
    month[10]="November";
    month[11]="December";
 
    var allTweens;
 
    init();
    animate();
 
    function init() {
        // create a chain of tweens
        allTweens = setupTweens(12);
        allTweens[0].start();
    }
 
    function setupTweens(imageCount) {
        var tweens = [];
        for (var i = 0 ; i < imageCount ; i++) {
            var tween = new TWEEN.Tween( { opac: 0, image: i, max: imageCount } )
                    .to( { opac: 100 }, 2500 )
                    .easing( TWEEN.Easing.Linear.None )
                    .onUpdate( function () {
                        // on update, lower the opacity of image i and update the opacity of
                        // image i+1;
                        var currentImage = document.getElementById('img-'+(this.image+1));
 
                        if (this.image == imageCount -1) {
                            var nextImage = document.getElementById('img-'+1);
                        } else {
                            var nextImage = document.getElementById('img-'+(this.image+2));
                        }
 
                        currentImage.style.opacity = 1- this.opac / 100;
                        nextImage.style.opacity = this.opac / 100;
                    } );
 
            tween.onComplete(function() {
 
                document.getElementById('title-2012').textContent = "Showing precipitation: " + month[this.image] + " " + 2012;
                // Set the inner variable to 0.
                this.opac = 0;
                // we're done, restart
                if (this.max-1 == this.image) {
                    allTweens[0].start();
                }
 
            });
            // connect to each another
            if (i > 0) {
                tweens[i-1].chain(tween);
            }
 
            tweens.push(tween);
            tweens[0].repeat();
        }
 
        return tweens;
    }
 
    function animate() {
        requestAnimationFrame(animate);
        TWEEN.update();
    }

Здесь мы используем tween.js для настройки переходов между изображениями.

В качестве бонуса: также создайте легенду, чтобы показать, что цвет означает, что

В анимации вы можете увидеть легенду внизу. Эта легенда была создана как простой холст, сохраненный как изображение. Для полноты кода, код для этого показан здесь:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var canvas = document.createElement("canvas");
    canvas.width = 435;
    canvas.height = 30;
    var context = canvas.getContext('2d');
 
    var domains = scale.domain();
    document.body.appendChild(canvas);
 
    // from 1 to 1700
    for (var i = 0 ; i < domains.length ; i++) {
        context.beginPath();
        context.rect(10+i*4,0,4,20);
        console.log(domains[i]);
        context.fillStyle = scale(domains[i]).hex();
        context.fill();
    }
 
    context.fillStyle = 'black';
    context.fillText("0 mm", 0, 30);
    context.fillText(Math.round(domains[25]) + " mm", 100, 30);
    context.fillText(Math.round(domains[50]) + " mm", 200, 30);
    context.fillText(Math.round(domains[75]) + " mm", 300, 30);
    context.fillText("1700 mm", 390, 30);

Здесь мы просто используем масштаб, который мы видели проще, и проходим домены, чтобы создать цветную легенду.