DoubleCloud 即将关闭。使用限时免费迁移服务迁移到 ClickHouse。立即联系我们 ->->

博客 / 工程

宣布 adsb.exposed - 使用 ClickHouse 对 ADS-B 飞行数据进行交互式可视化和分析

author avatar
Alexey Milovidov
2024 年 4 月 24 日

您可能听说过航班雷达(实时航班追踪地图),并且在追蹤天空中的飞机时玩得很开心,但在这篇博文中,我们将向您介绍更酷的东西!

ADS-B(自动应答器依赖监视广播)是一种无线电协议,用于广播各种飞行数据。我们的联合创始人兼 CTO Alexey Milovidov 在此数据上构建了一个交互式可视化和分析工具。如果还有什么,他在这过程中发明了一种全新的艺术形式!

newyork.png 曼哈顿上空的直升机

该网站聚合并可视化了大量的空中交通数据。这些数据托管在 ClickHouse 数据库中,并进行实时查询。您可以使用自定义 SQL 查询调整可视化效果,并从 500 亿条记录钻取到单个数据记录。结果是一些非常壮观的图像!

虽然这篇博文主要关注演示的构建方式,但请随时 跳到最后,观看一些令人惊叹的视觉效果。

或者,访问 https://adsb.exposed,找到您所在城市的视觉效果,并将其分享到社交媒体上!我们为最美丽的图像提供一件 ClickHouse T 恤,获胜者将在 我们下次社区电话会议 上宣布,时间为 4 月 30 日。

此演示的完整源代码可以在这里找到 这里

如何获取 ADS-B 数据?

ADS-B 由安装在飞机(不仅是飞机)上的“应答器”广播。该协议未加密,并且在收集、使用或重新分发此数据方面没有任何限制。大多数客机有义务广播此数据,甚至滑翔机、无人机和某些国家的机场地面车辆。军用飞机和私人轻型飞机可能会或可能不会广播。

可以使用您自己的无线电接收器(例如,SDR 形式)从空中收集此数据,但您的接收器只能看到您所在位置特定范围内的数据。存在用于共享和交换此数据的平台。一些平台邀请参与者共享数据,但通过提供商业访问来限制其重新分发。虽然飞机广播的源数据本质上是公共领域的,但这些公司可能会从这些数据中制作和许可衍生作品。

我们使用来自两个来源的数据:ADSB.lol(提供无限制的完整历史数据:每天 3000 万到 5000 万条记录,自 2023 年起根据 开放数据库许可 提供)和 ADSB-Exchange(只提供每个月第一天数据的样本:每天约 12 亿条记录,覆盖率更高)。

另一个有希望的数据源,airplanes.live,引起了我们的注意。作者表示愿意为非商业用途提供历史和实时数据。它具有很好的覆盖范围和数据质量,我们将在接下来的几天内将其纳入。

实施细节

该网站实现为单个 HTML 页面。它不使用 JavaScript 框架,源代码也没有缩小,因此易于阅读。

渲染地图

使用 Leaflet 库以两层显示地图。背景层使用来自 OpenStreetMap 的图块来创建典型的地理地图。主层将可视化效果叠加到背景地图之上。

可视化层使用具有自定义回调函数 createTileGridLayer,该函数会动态生成 Canvas 元素

L.GridLayer.ClickHouse = L.GridLayer.extend({
	createTile: function(coords, done) {
    	let tile = L.DomUtil.create('canvas', 'leaflet-tile');
    	tile.width = 1024;
    	tile.height = 1024;
    	render(this.options.table, this.options.priority, coords, tile).then(err => done(err, tile));
    	return tile;
	}
});

const layer_options = {
	tileSize: 1024,
	minZoom: 2,
	maxZoom: 19,
	minNativeZoom: 2,
	maxNativeZoom: 16,
	attribution: '(c) Alexey Milovidov, ClickHouse, Inc. (data: adsb.lol, adsbexchange.com)'
};

每个图块具有 1024x1024 的高分辨率,以减少对数据库的请求次数。

渲染函数使用 JavaScript 的 fetch 函数通过其 HTTP API 向 ClickHouse 发出请求

const query_id = `${uuid}-${query_sequence_num}-${table}-${coords.z - 2}-${coords.x}-${coords.y}`;
const hosts = getHosts(key);
const url = host => `${host}/?user=website&default_format=RowBinary` +
	`&query_id=${query_id}&replace_running_query=1` +
	`&param_table=${table}&param_sampling=${[0, 100, 10, 1][priority]}` +
	`&param_z=${coords.z - 2}&param_x=${coords.x}&param_y=${coords.y}`;

progress_update_period = 1;
const response = await Promise.race(hosts.map(host => fetch(url(host), { method: 'POST', body: sql })));

用户可以使用页面上的表单动态编辑 SQL 查询以调整可视化效果。此参数化查询接受图块坐标 (x, y) 和缩放级别作为参数。

查询以 RowBinary 格式返回图像每个像素的 RGBA 值(1024x1024 像素,1048576 行,每行 4 字节,每个图块共 4 MiB)。只要浏览器支持,它就会在 HTTP 响应中使用 ZSTD 压缩。值得注意的是,ZSTD 压缩比 PNG 在原始像素位图上效果更好!(虽然这并不令人惊讶)。

虽然图像数据经常被压缩多次,但仍有数百兆字节需要通过网络传输。这就是为什么在网络连接不良的情况下,该服务可能会感觉很慢的原因。

let ctx = tile.getContext('2d');
let image = ctx.createImageData(1024, 1024, {colorSpace: 'display-p3'});
let arr = new Uint8ClampedArray(buf);

for (let i = 0; i < 1024 * 1024 * 4; ++i) { image.data[i] = arr[i]; }

ctx.putImageData(image, 0, 0, 0, 0, 1024, 1024);

使用“显示 P3”颜色空间将数据放在画布上,以便在支持的浏览器中获得更广的色域。

我们使用三个具有不同详细程度的表:planes_mercator 包含 100% 的数据,planes_mercator_sample10 包含 10%,planes_mercator_sample100 包含 1%。加载从 1% 的样本开始,即使在渲染整个世界时也能提供即时响应。加载第一个详细程度后,它将继续加载下一个 10% 的详细程度,然后再加载 100% 的数据。这在渐进加载中提供了很好的效果。

图像数据还使用简单的 JavaScript 对象缓存在客户端

if (!cached_tiles[key]) cached_tiles[key] = [];
/// If there is a higer-detail tile, skip rendering of this level of detal.
if (cached_tiles[key][priority + 1]) return;
buf = cached_tiles[key][priority];

唯一的缺点是,浏览一段时间后,页面会占用太多内存——这是未来版本需要解决的问题。

数据库和查询

按照 ClickHouse 标准,该数据库很小。截至 2024 年 3 月 29 日,planes_mercator 表中有 444.7 亿行,并且正在不断更新新的记录。它占用 1.6 TB 的磁盘空间。

表架构如下(您可以在 setup.sql 源代码中阅读)。

CREATE TABLE planes_mercator
(
    `mercator_x` UInt32 MATERIALIZED 4294967295 * ((lon + 180) / 360),
    `mercator_y` UInt32 MATERIALIZED 4294967295 * ((1 / 2) - ((log(tan(((lat + 90) / 360) * pi())) / 2) / pi())),
    `time` DateTime64(3),
    `date` Date,
    `icao` String,
    `r` String,
    `t` LowCardinality(String),
    `dbFlags` Int32,
    `noRegData` Bool,
    `ownOp` LowCardinality(String),
    `year` UInt16,
    `desc` LowCardinality(String),
    `lat` Float64,
    `lon` Float64,
    `altitude` Int32,
    `ground_speed` Float32,
    `track_degrees` Float32,
    `flags` UInt32,
    `vertical_rate` Int32,
    `aircraft_alert` Int64,
    `aircraft_alt_geom` Int64,
    `aircraft_gva` Int64,
    `aircraft_nac_p` Int64,
    `aircraft_nac_v` Int64,
    `aircraft_nic` Int64,
    `aircraft_nic_baro` Int64,
    `aircraft_rc` Int64,
    `aircraft_sda` Int64,
    `aircraft_sil` Int64,
    `aircraft_sil_type` LowCardinality(String),
    `aircraft_spi` Int64,
    `aircraft_track` Float64,
    `aircraft_type` LowCardinality(String),
    `aircraft_version` Int64,
    `aircraft_category` Enum8('A0', 'A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7', 'B0', 'B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'C0', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7', 'D0', 'D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7', ''),
    `aircraft_emergency` Enum8('', 'none', 'general', 'downed', 'lifeguard', 'minfuel', 'nordo', 'unlawful', 'reserved'),
    `aircraft_flight` LowCardinality(String),
    `aircraft_squawk` String,
    `aircraft_baro_rate` Int64,
    `aircraft_nav_altitude_fms` Int64,
    `aircraft_nav_altitude_mcp` Int64,
    `aircraft_nav_modes` Array(Enum8('althold', 'approach', 'autopilot', 'lnav', 'tcas', 'vnav')),
    `aircraft_nav_qnh` Float64,
    `aircraft_geom_rate` Int64,
    `aircraft_ias` Int64,
    `aircraft_mach` Float64,
    `aircraft_mag_heading` Float64,
    `aircraft_oat` Int64,
    `aircraft_roll` Float64,
    `aircraft_tas` Int64,
    `aircraft_tat` Int64,
    `aircraft_true_heading` Float64,
    `aircraft_wd` Int64,
    `aircraft_ws` Int64,
    `aircraft_track_rate` Float64,
    `aircraft_nav_heading` Float64,
    `source` LowCardinality(String),
    `geometric_altitude` Int32,
    `geometric_vertical_rate` Int32,
    `indicated_airspeed` Int32,
    `roll_angle` Float32,
    INDEX idx_x mercator_x TYPE minmax GRANULARITY 1,
    INDEX idx_y mercator_y TYPE minmax GRANULARITY 1
)
ENGINE = MergeTree
ORDER BY (mortonEncode(mercator_x, mercator_y), time)

此架构包含经纬度值,这些值会通过 MATERIALIZED 列 自动转换为 Web-Mercator 投影中的坐标。Leaflet 软件和互联网上大多数地图都使用此投影。Mercator 坐标存储在 UInt32 中,这使得在 SQL 查询中使用瓦片坐标和缩放级别进行算术运算变得容易。

Web Mercator 的莫顿曲线使用 minmax 索引 对表坐标进行排序,允许查询某些瓦片时只读取所需数据。

物化视图 用于生成不同详细级别的表。

CREATE TABLE planes_mercator_sample10 AS planes_mercator;

CREATE TABLE planes_mercator_sample100 AS planes_mercator;

CREATE MATERIALIZED VIEW view_sample10 TO planes_mercator_sample10
AS SELECT *
FROM planes_mercator
WHERE (rand() % 10) = 0;

CREATE MATERIALIZED VIEW view_sample100 TO planes_mercator_sample100
AS SELECT *
FROM planes_mercator
WHERE (rand() % 100) = 0;

使用 ClickHouse Cloud

我们在 ClickHouse Cloud 的预发布环境中使用一项服务。预发布环境用于测试新 ClickHouse 版本以及我们实现的新型基础设施。

例如,我们可以尝试不同类型的机器和不同规模的服务,或者我们可以测试开发中的新功能,例如分布式缓存。

预发布环境还使用故障注入:我们以一定概率中断网络连接,以确保服务正常运行。此外,它利用混沌工程:我们随机终止 clickhouse-serverclickhouse-keeper 的各种机器,并随机将服务缩放回不同的机器数量。这就是这个项目如何促进我们服务的开发和测试。

最后,请求也被负载均衡到备份服务。哪个服务先返回,就会使用哪个服务。这就是我们在使用预发布环境的同时避免停机的方法。

示例查询:波音 vs. 空客

考虑以下相当热门的可视化:"波音 vs. 空客"

Markdown Image

让我们看一下此可视化的 SQL 查询。

WITH
	bitShiftLeft(1::UInt64, {z:UInt8}) AS zoom_factor,
	bitShiftLeft(1::UInt64, 32 - {z:UInt8}) AS tile_size,

	tile_size * {x:UInt16} AS tile_x_begin,
	tile_size * ({x:UInt16} + 1) AS tile_x_end,

	tile_size * {y:UInt16} AS tile_y_begin,
	tile_size * ({y:UInt16} + 1) AS tile_y_end,

	mercator_x >= tile_x_begin AND mercator_x < tile_x_end
	AND mercator_y >= tile_y_begin AND mercator_y < tile_y_end AS in_tile,

	bitShiftRight(mercator_x - tile_x_begin, 32 - 10 - {z:UInt8}) AS x,
	bitShiftRight(mercator_y - tile_y_begin, 32 - 10 - {z:UInt8}) AS y,

	y * 1024 + x AS pos,

	count() AS total,
	sum(desc LIKE 'BOEING%') AS boeing,
	sum(desc LIKE 'AIRBUS%') AS airbus,
	sum(NOT (desc LIKE 'BOEING%' OR desc LIKE 'AIRBUS%')) AS other,

	greatest(1000000 DIV {sampling:UInt32} DIV zoom_factor, total) AS max_total,
	greatest(1000000 DIV {sampling:UInt32} DIV zoom_factor, boeing) AS max_boeing,
	greatest(1000000 DIV {sampling:UInt32} DIV zoom_factor, airbus) AS max_airbus,
	greatest(1000000 DIV {sampling:UInt32} DIV zoom_factor, other) AS max_other,

	pow(total / max_total, 1/5) AS transparency,

	255 * (1 + transparency) / 2 AS alpha,
	pow(boeing, 1/5) * 256 DIV (1 + pow(max_boeing, 1/5)) AS red,
	pow(airbus, 1/5) * 256 DIV (1 + pow(max_airbus, 1/5)) AS green,
	pow(other, 1/5) * 256 DIV (1 + pow(max_other, 1/5)) AS blue

SELECT round(red)::UInt8, round(green)::UInt8, round(blue)::UInt8, round(alpha)::UInt8
FROM {table:Identifier}
WHERE in_tile
GROUP BY pos ORDER BY pos WITH FILL FROM 0 TO 1024*1024

查询的第一部分计算条件 in_tile,该条件用于 WHERE 部分以筛选请求的瓦片中的数据。然后计算颜色:alpha、red、green 和 blue。它们由 pow 函数调整,以实现更好的均匀性,并限制在 0..255 范围内,并转换为 UInt8。采样参数用于调整,因此具有较低详细级别的查询将返回颜色相对一致的图片。我们按像素坐标 pos 分组,并在 ORDER BY 中使用 WITH FILL 修饰符 以填充没有数据的像素位置的零。因此,我们将获得一个具有精确 1024x1024 大小的 RGBA 位图。

报告

如果您使用鼠标右键选择一个区域或使用选择工具,它将从数据库中生成选择区域的报告。这完全很简单。例如,以下是顶级飞机类型的查询

const sql_types = `
	WITH mercator_x >= {left:UInt32} AND mercator_x < {right:UInt32}
    	AND mercator_y >= {top:UInt32} AND mercator_y < {bottom:UInt32} AS in_tile
	SELECT t, anyIf(desc, desc != '') AS desc, count() AS c
	FROM {table:Identifier}
	WHERE t != '' AND ${condition}
	GROUP BY t
	ORDER BY c DESC
	LIMIT 100`;

报告是针对航班号、飞机类型、注册号(尾号)和所有者计算的。您可以点击任何项目,它将对主 SQL 查询应用筛选器。例如,点击 A388 将显示空客 380-800 的可视化。

作为奖励,如果您将光标悬停在飞机类型上,它将转到维基百科 API 并尝试查找该飞机的图片。不过,它通常会在维基百科上找到其他内容。

保存的查询

您可以编辑查询,然后分享链接。查询将被转换为 128 位哈希值并保存在同一个 ClickHouse 数据库中。

async function saveQuery(text) {
	const sql = `INSERT INTO saved_queries (text) FORMAT RawBLOB`;
	const hosts = getHosts(null);
	const url = host => `${host}/?user=website_saved_queries&query=${encodeURIComponent(sql)}`;
	const response = await Promise.all(hosts.map(host => fetch(url(host), { method: 'POST', body: text })));
}

async function loadQuery(hash) {
	const sql = `SELECT text FROM saved_queries WHERE hash = unhex({hash:String}) LIMIT 1`;
	const hosts = getHosts(null);
	const url = host => `${host}/?user=website_saved_queries&default_format=JSON&param_hash=${hash}`;
	const response = await Promise.race(hosts.map(host => fetch(url(host), { method: 'POST', body: sql })));
	const data = await response.json();
	return data.data[0].text;
}

我们使用不同的用户 website_saved_queries,该用户具有不同的访问控制和配额来处理这些查询。

进度条

显示一个进度条,其中包含按行和字节处理的数据量,这很好。

const sql = `SELECT
        	sum(read_rows) AS r,
        	sum(total_rows_approx) AS t,
        	sum(read_bytes) AS b,
        	r / max(elapsed) AS rps,
        	b / max(elapsed) AS bps,
        	formatReadableQuantity(r) AS formatted_rows,
        	formatReadableSize(b) AS formatted_bytes,
        	formatReadableQuantity(rps) AS formatted_rps,
        	formatReadableSize(bps) AS formatted_bps
    	FROM clusterAllReplicas(default, system.processes)
    	WHERE user = 'website' AND startsWith(query_id, {uuid:String})`;

	const hosts = getHosts(uuid);
	const url = host => `${host}/?user=website_progress&default_format=JSON&param_uuid=${uuid}`;

	let responses = await Promise.all(hosts.map(host => fetch(url(host), { method: 'POST', body: sql })));
	let datas = await Promise.all(responses.map(response => response.json()));

我们从集群中所有服务器的 system.processes 表中进行选择。它不会显示精确的进度,因为并行请求了多个瓦片,并且有多个查询,其中一些已完成,而另一些仍在进行中。查询将只看到正在进行的查询,因此处理的总记录将低于实际值。

当我们加载第一个详细级别、第二个详细级别等时,我们也会将进度条的颜色设置为不同。

缓存局部性

ClickHouse Cloud 中的服务可以使用多个副本,默认情况下,请求将路由到任意一个健康的副本。处理大量数据的查询将在多个副本中并行化,而简单的查询将只使用单个副本。数据存储在 AWS S3 中,每个副本 pod 还具有一个本地附加的 SSD,用于缓存,因此内存中的页面缓存也会影响最终的查询时间。

很棒的可视化

下面我们展示了一些 Alexey 选择的初始图像。这只是触及了表面,该网站提供了一个免费墙纸的宝库!

Markdown Image

如果我们 放大到机场,我们可以看到飞机停在哪里,甚至可以根据制造商或航空公司对它们进行颜色编码。

Markdown Image

Markdown Image

Markdown Image

Markdown Image

Markdown Image

Markdown Image

Markdown Image

Markdown Image

Markdown Image

在迪拜机场,绿色的毛线球是阿联酋工程的机库,在那里维护空客飞机。

Markdown Image

Markdown Image

Markdown Image

立即开始使用 ClickHouse Cloud,并获得 300 美元的信用额度。在您的 30 天试用期结束后,您可以继续使用按使用付费计划,或 联系我们 了解有关我们的基于使用量的折扣的更多信息。访问我们的 价格页面 了解更多详情。

分享此帖子

订阅我们的时事通讯

及时了解功能发布、产品路线图、支持和云产品!
正在加载表单...
关注我们
Twitter imageSlack imageGitHub image
Telegram imageMeetup imageRss image
©2024ClickHouse, Inc. 总部位于加利福尼亚州湾区和荷兰阿姆斯特丹。