きっかけ
Rustで1からGISを作っている例が少なかったので作ってみようと思いました。
内容
- 今回実装したのはGISの機能のほんの一部ですが、基礎的な部分から作っています。学習資料として見ていただければと思います。
- マルチスレッド処理やベクターデータの表示は次回以降でやろうと思います。
使用した言語・ライブラリ
- Rust言語
- egui (デスクトップアプリ用ライブラリ)
- proj-core (投影法の変換ライブラリ)
- ogc-tms (XYZ形式の変換ライブラリ)
実装した主な機能
- ラスタータイル(地理院地図などのベースマップ)の表示
- ズーム・視点移動などの2D地図の基礎的な操作
- レイヤー表示切替とスライダーによるレイヤー透明度設定
- ドラッグによる表示順の入れ替え
実装したアプリの見た目(レイヤーコントローラーと地図)
動作イメージ
今回作成したデモアプリのソースコード
地図を表示するのに必要な情報
地図を画面に表示したければ、最初に緯度・経度・ズームレベルを指定する必要があります。
ラスタータイル(ベースマップ)について
下に示すのは地理院地図のラスタータイルの取得URLです。ラスタータイルを表示したければ、サーバー上の画像をXYZ形式のタイル番号を指定して取得する必要があります。
なお、1枚のタイルは、256pxの正方形です。
https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png
地図エンジン(計算を担当する部分)に必要な引数と返り値
今回は地図エンジンを一つの関数(create_map_obj)で作ることにしました。引数と返り値を示します。
引数
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MapXYZ {
pub latitude: f64, //緯度
pub longitude: f64, //経度
pub zoom: u32,//ズームレベル
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MoveXY {
pub move_x: f64,//移動ピクセルx
pub move_y: f64,//移動ピクセルy
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Screen {
pub width: f64,//スクリーンの幅
pub height: f64,//スクリーンの高さ
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MapEngineInput {
pub map_xyz: MapXYZ,
pub screen: Screen,
pub movement: MoveXY,
}
上に示したMapEngineInputが地図エンジンの引数となります。
返り値
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TileZXY {
pub x: u32,
pub y: u32,
pub z: u32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct MapObject {
// WGS84: [longitude, latitude]
pub center: [f64; 2],
// WGS84: [min_lon, min_lat, max_lon, max_lat]
pub bbox: [f64; 4],
pub tiles: Vec<TileZXY>,
// 最初のタイルの画面上の配置位置 (px)
pub offset: [f64; 2],
}
返り値は、center、bbox、タイル一覧、左上のタイルのオフセット位置を返します。
地図エンジンのソースコード
crates\engine\src\lib.rs
pub use domain::types::gis_engine_type::{
MapEngineInput, MapObject, MapXYZ, MoveXY, Screen, TileZXY,
};
use ogc_tms::{BoundingBox, TileMatrixSet};
use proj_core::Transform;
/// MapXYZ が有効かどうかを検証する。
pub fn validate_map_xyz(xyz: &MapXYZ) -> bool {
if !xyz.latitude.is_finite() || !(-90.0..=90.0).contains(&xyz.latitude) {
return false;
}
if !xyz.longitude.is_finite() || !(-180.0..=180.0).contains(&xyz.longitude) {
return false;
}
xyz.zoom <= 30
}
/// MoveXY が有効かどうかを検証する。
pub fn validate_move(movement: &MoveXY) -> bool {
movement.move_x.is_finite() && movement.move_y.is_finite()
}
/// Screen が有効かどうかを検証する。
pub fn validate_screen(screen: &Screen) -> bool {
screen.width.is_finite()
&& screen.height.is_finite()
&& screen.width > 0.0
&& screen.height > 0.0
}
/// マップオブジェクトを作成する。
///
/// movement は画面上のドラッグ移動量 (px)。
/// 右方向・下方向へのドラッグを正の値とする。
pub fn create_map_obj(input: &MapEngineInput) -> Result<MapObject, Box<dyn std::error::Error>> {
let xyz = input.map_xyz;
let screen = input.screen;
let movement = input.movement;
// 入力値の検証
if !validate_map_xyz(&xyz) {
return Err("緯度、経度、ズームレベルが無効です".into());
}
if !validate_move(&movement) {
return Err("不正なピクセル移動量です".into());
}
if !validate_screen(&screen) {
return Err("Screenの大きさが不正です".into());
}
let zoom = xyz.zoom as u8;
// XYZ / WebMercatorQuad
let base_tms = ogc_tms::web_mercator_quad();
let tms: TileMatrixSet = base_tms.extended_to(zoom)?;
// WGS84 <-> Web Mercator
let forward = Transform::new("EPSG:4326", "EPSG:3857")?;
let inverse = forward.inverse()?;
// Web Mercator の有効な緯度範囲に制限
let lat = xyz
.latitude
.clamp(-ogc_tms::MERCATOR_LAT_LIMIT, ogc_tms::MERCATOR_LAT_LIMIT);
// 移動前の中心座標を投影
let (cx, cy) = forward.convert((xyz.longitude, lat))?;
// 解像度 (m/px)
let resolution = tms.resolution(zoom)?;
// ドラッグ移動量を投影座標に反映
// 右ドラッグ: 中心は西へ移動
// 下ドラッグ: 中心は北へ移動
let moved_cx = cx - movement.move_x * resolution;
let moved_cy = cy + movement.move_y * resolution;
// 移動後の中心座標を WGS84 に変換
let (center_lon, center_lat) = inverse.convert((moved_cx, moved_cy))?;
let center = [center_lon, center_lat];
// 表示範囲を計算
let half_w = screen.width * resolution / 2.0;
let half_h = screen.height * resolution / 2.0;
let left = moved_cx - half_w;
let right = moved_cx + half_w;
let bottom = moved_cy - half_h;
let top = moved_cy + half_h;
let projected_bbox = BoundingBox {
left,
bottom,
right,
top,
};
// 表示範囲に交差するタイルを列挙
let mut tiles: Vec<TileZXY> = tms
.tiles(projected_bbox, zoom..=zoom)?
.map(|tile| -> Result<TileZXY, std::num::TryFromIntError> {
Ok(TileZXY {
x: u32::try_from(tile.x)?,
y: u32::try_from(tile.y)?,
z: u32::try_from(tile.z)?,
})
})
.collect::<Result<Vec<_>, _>>()?;
// 上から下、左から右の順番にする
tiles.sort_by_key(|tile| (tile.y, tile.x));
// 左上の座標を含むタイル
let first_tile = tms.xy_tile(left, top, zoom)?;
let first_bounds = tms.xy_bounds(first_tile)?;
// 最初のタイルの画面上の配置位置 (px)
let offset = [
(first_bounds.left - left) / resolution,
(top - first_bounds.top) / resolution,
];
// 表示範囲の対角の座標を WGS84 に変換
let (min_lon, min_lat) = inverse.convert((left, bottom))?;
let (max_lon, max_lat) = inverse.convert((right, top))?;
let bbox = [min_lon, min_lat, max_lon, max_lat];
Ok(MapObject {
center,
bbox,
tiles,
offset,
})
}
→引数(緯度経度など)をセットすると、返り値として地図表示に必要なXYZ形式のタイル番号の配列を受け取れる
地図画面の描画のソースコード
crates\window\src\apps\central_panel\central_panel_main.rs
use crate::apps::store::map_engine_store::{MapEngineStore, MapEngineStoreTrait};
use crate::apps::store::raster_tile_layer_store::{
RasterTileLayerStore, RasterTileLayerStoreTrait,
};
use domain::params::map_config::{MAP_MAX_ZOOM_LEVEL, MAP_MIN_ZOOM_LEVEL, MAP_TILE_SIZE};
use domain::types::gis_engine_type::{MapObject, MapXYZ, MoveXY, Screen};
use eframe::egui;
use engine::create_map_obj;
pub struct CentralPanelMain {
pub map_engine_store: MapEngineStore,
pub raster_tile_layer_store: RasterTileLayerStore,
}
impl Default for CentralPanelMain {
fn default() -> Self {
Self {
map_engine_store: MapEngineStore::default(),
raster_tile_layer_store: RasterTileLayerStore::default(),
}
}
}
impl CentralPanelMain {
/// 中央パネルを描画する。
///
/// # Arguments
/// * `ui` - 描画するUIコンテキスト
/// * `ctx` - eguiのコンテキスト
/// * `engine_store` - 地図エンジンの状態を管理するストア
/// * `raster_tile_layer_store` - ラスタータイルレイヤーの状態を管理するストア
pub fn use_central_panel_main(
&mut self,
ui: &mut egui::Ui,
engine_store: &mut MapEngineStore,
raster_tile_layer_store: &RasterTileLayerStore,
) {
let ctx = ui.ctx().clone();
// サイドパネルを除いた中央パネルのサイズを取得
let viewport = ui.available_rect_before_wrap();
let available_size = viewport.size();
if available_size.x <= 0.0 || available_size.y <= 0.0 {
return;
}
// 描画領域のサイズを設定
engine_store.set_screen(Screen {
width: available_size.x as f64,
height: available_size.y as f64,
});
// 地図領域の入力を取得
let response = ui.interact(
viewport,
ui.id().with("map_viewport"),
egui::Sense::click_and_drag(),
);
// ズーム処理
Self::handle_zoom_event(&response, &ctx, engine_store);
// パン移動量はフレームごとに適用し、前回の地図中心を基準にする。
let movement = Self::handle_pan_event(&response, &ctx);
// 地図エンジンに渡す入力を組み立てる。
let mut engine_input = engine_store.get_map_engine_input();
let previous_map_center = engine_store
.get_map_object()
.map(|map_object| map_object.center);
if let Some(center) = previous_map_center {
engine_input.map_xyz = MapXYZ {
latitude: center[1],
longitude: center[0],
zoom: engine_store.get_zoom(),
};
}
engine_input.movement = movement;
let map_obj = match create_map_obj(&engine_input) {
Ok(map_obj) => map_obj,
Err(error) => {
eprintln!("GISエンジンのエラーです: {error}");
return;
}
};
let map_xyz = MapXYZ {
latitude: map_obj.center[1],
longitude: map_obj.center[0],
zoom: engine_input.map_xyz.zoom,
};
// ラスタータイル描画
Self::render_raster_tiles(ui, &map_obj, raster_tile_layer_store);
engine_store.set_xyz(map_xyz);
engine_store.set_map_object(map_obj);
}
/// マウスホイールによるズーム操作を処理する。
///
/// # Arguments
/// * `ui` - 描画するUIコンテキスト
/// * `ctx` - eguiのコンテキスト
/// * `engine_store` - 地図エンジンの状態を管理する
fn handle_zoom_event(
response: &egui::Response,
ctx: &egui::Context,
engine_store: &mut MapEngineStore,
) {
if !response.hovered() {
return;
}
let scroll_directions: Vec<f32> = ctx.input(|input| {
input
.events
.iter()
.filter_map(|event| {
if let egui::Event::MouseWheel { delta, .. } = event {
(delta.y != 0.0).then_some(delta.y.signum())
} else {
None
}
})
.collect()
});
if scroll_directions.is_empty() {
return;
}
let mut zoom = engine_store.get_zoom();
for direction in scroll_directions {
zoom = if direction > 0.0 {
//ズームレベルが最大値でない場合、ズームレベルを1つ増やす
zoom.saturating_add(1).min(MAP_MAX_ZOOM_LEVEL)
} else {
//ズームレベルが最小値でない場合、ズームレベルを1つ減らす
zoom.saturating_sub(1).max(MAP_MIN_ZOOM_LEVEL)
};
}
engine_store.set_zoom(zoom);
ctx.request_repaint();
}
/// マウスドラッグによるフレーム単位の移動量を返す。
fn handle_pan_event(response: &egui::Response, ctx: &egui::Context) -> MoveXY {
if response.dragged() || response.drag_stopped() {
let delta = ctx.input(|input| input.pointer.delta());
ctx.request_repaint();
MoveXY {
move_x: f64::from(delta.x),
move_y: f64::from(delta.y),
}
} else {
MoveXY {
move_x: 0.0,
move_y: 0.0,
}
}
}
/// ラスタータイルを描画する。
///
/// # Arguments
/// * `ui` - 描画するUIコンテキスト
/// * `map_obj` - 地図オブジェクト
/// * `raster_tile_layer_store` - ラスタータイルレイヤーの状態を管理するストア
fn render_raster_tiles(
ui: &mut egui::Ui,
map_obj: &MapObject,
raster_tile_layer_store: &RasterTileLayerStore,
) {
// タイルがない場合は描画しない
if map_obj.tiles.is_empty() {
return;
}
let viewport = ui.available_rect_before_wrap();
let origin = viewport.min;
// 最初のタイルを基準に配置
let first_tile = &map_obj.tiles[0];
let offset_x = map_obj.offset[0] as f32;
let offset_y = map_obj.offset[1] as f32;
let layers = raster_tile_layer_store.get_all_raster_tile_layers();
ui.scope_builder(
egui::UiBuilder::new()
.max_rect(viewport)
.layout(egui::Layout::top_down(egui::Align::Min)),
|ui| {
ui.set_clip_rect(viewport);
// 下のレイヤーから描画
for layer in layers.iter().rev() {
if !layer.visible || layer.opacity <= 0.0 {
continue;
}
let alpha = (layer.opacity.clamp(0.0, 1.0) * 255.0).round() as u8;
for tile in &map_obj.tiles {
let x = origin.x
+ offset_x
+ (tile.x as f32 - first_tile.x as f32) * MAP_TILE_SIZE;
let y = origin.y
+ offset_y
+ (tile.y as f32 - first_tile.y as f32) * MAP_TILE_SIZE;
let rect = egui::Rect::from_min_size(
egui::pos2(x, y),
egui::vec2(MAP_TILE_SIZE, MAP_TILE_SIZE),
);
if !rect.intersects(viewport) {
continue;
}
let url = layer
.url
.replace("{z}", &tile.z.to_string())
.replace("{x}", &tile.x.to_string())
.replace("{y}", &tile.y.to_string());
ui.put(
rect,
egui::Image::new(url).tint(egui::Color32::from_white_alpha(alpha)),
);
}
}
},
);
}
}
地図エンジンを動かし返り値をストアに保存し、次の画面更新時の引数を作成する形でGISが動いています!

