😾

Scratchを拡張しよう!(2)地図を表示

2022/11/06に公開

はじめに

CoderDojoというボランティア団体で、子供達のプログラミングスキル習得の支援をしております。プログラミングの開発環境は、主にScratchを使っていますが、標準機能だけでは飽き足らず、このシリーズではScratchの拡張機能開発に挑戦します!今回は地図表示に挑戦します。

地図表示の拡張機能は、Junya Ishiharaさんが開発したGeo Scratchがよくできていますのでそちらをご利用頂くのがオススメですが、今回は画像表示の練習として取り組んでみました。

https://github.com/geolonia/x-geo-scratch

完成したサイトはこちらになります。

地図表示拡張機能付きScratch

地図表示拡張機能付きScratch
地図表示拡張機能付きScratch

拡張機能から地図を追加すると、

  • 緯度、経度、ズームレベル(0〜18)を指定しての地図を表示できます。デフォルトは東京都庁を表示します。※ちなみに地図を消去する機能がないのでご注意下さい。

今回表示する地図はオープンデータのOpen Street Mapを利用しています。著作権はこちらをご参照下さい。

下準備

下準備は(1)天気予報を取得を参照下さい。

scratch-gui側の開発

scratch-gui/src/lib/libraries/extensionsopenstreetmapディレクトリを作りましょう。この中にScratchの拡張機能のアイコンを格納します。ラージサイズのアイコンは600px x 372px, スモールサイズのアイコンは80px x 80pxになります。

ラージサイズ
ラージサイズ 600px x 372px

スモールサイズ
スモールサイズ 80px x 80px

この後は、ソースコードの修正になります。scratch-gui/src/lib/libraries/extensions/index.jsxに以下のコードを追加します。

...中略

import gdxforInsetIconURL from './gdxfor/gdxfor-small.svg';
import gdxforConnectionIconURL from './gdxfor/gdxfor-illustration.svg';
import gdxforConnectionSmallIconURL from './gdxfor/gdxfor-small.svg';

import openMeteoIconURL from './openmeteo/open-meteo.png';
import openMeteoInsetIconURL from './openmeteo/open-meteo-small.png';

import openStreetMapIconURL from './openstreetmap/open-street-map.png'; // この行を追加
import openStreetMapInsetIconURL from './openstreetmap/open-street-map-small.png'; // この行を追加

...中略

       connectingMessage: (
            <FormattedMessage
                defaultMessage="Connecting"
                description="Message to help people connect to their force and acceleration sensor."
                id="gui.extension.gdxfor.connectingMessage"
            />
        ),
        helpLink: 'https://scratch.mit.edu/vernier'
    },
    {
        name: '天気予報(てんきよほう)',
        extensionId: 'openMeteo',
        iconURL: openMeteoIconURL,
        insetIconURL: openMeteoInsetIconURL,
        description: '天気予報を取得します(てんきよほうをしゅとくします)',
        internetConnectionRequired: true,
        featured: true
    },
    // 最後に以下の行を追加
    {
        name: '地図(ちず)',
        extensionId: 'openStreetMap',
        iconURL: openStreetMapIconURL,
        insetIconURL: openStreetMapInsetIconURL,
        description: '地図を表示します(ちずをひょうじします)。地図の著作権はhttps://www.openstreetmap.orgのcopyrightを参照ください。©︎OpenStreetMap contributors',
        internetConnectionRequired: true,
        featured: true,
        helpLink: 'https://www.openstreetmap.org/copyright'
    }
    // ここまで
];

今回も、多言語対応、ひらがな表示対応まで設定したかったのですが、色々大変そうなため日本語表示のみにしています。

scratch-vm側の開発

scratch-vmの開発も(1)天気予報を取得と同様ですので参考にして下さい。今回はOpen Street Mapの地図表示がキモですので、実装の概要をまとめます。

  1. Open Street Mapでは256px x 256pxのタイル状の地図を以下のAPIで提供します。
    https://a.tile.openstreetmap.org/[z]/[x]/[y].png
  2. ここで、[z]はズームレベル、[x]は地図のx方向のタイル番号、[y]はy方向のタイル番号になります。
  3. ズームレベルが0の時は、256px x 256pxの1つのタイルに全世界のマップが描かれ、ズームレベルが1の時は2x2の4つのタイルに全世界が描かれ、と、以降ズームレベルが増えるごとに倍々でタイル数が増える構造です。
  4. 緯度と経度からx,yに相互変換する方法につきましてはSlippy map tilenamesを参照下さい。コードサンプルもありますので、ほぼそのまま活用させていただきました。
  5. 今回はタイルの処理が複雑なため、緯度、経度から表示するタイル情報を計算するクラスTileMapと、タイル情報から地図を取得しキャッシュするクラスTileCacheを作りました。

https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames

src/extensionsディレクトリにscratch3_open_street_mapディレクトリを作成し、以下のindex.js, tile-map.js, tile-cache.jsを追加ください。

index.js

const ArgumentType = require('../../extension-support/argument-type');
const BlockType = require('../../extension-support/block-type');
const Cast = require('../../util/cast');
const StageLayering = require('../../engine/stage-layering');
const TileMap = require('./tile-map');
const TileCache = require('./tile-cache');

/**
 * Icon svg to be displayed at the left edge of each extension block, encoded as a data URI.
 * @type {string}
 */
// eslint-disable-next-line max-len
const blockIconURI = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+Cjxzdmcgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgdmlld0JveD0iMCAwIDQwIDQwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHhtbDpzcGFjZT0icHJlc2VydmUiIHhtbG5zOnNlcmlmPSJodHRwOi8vd3d3LnNlcmlmLmNvbS8iIHN0eWxlPSJmaWxsLXJ1bGU6ZXZlbm9kZDtjbGlwLXJ1bGU6ZXZlbm9kZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLW1pdGVybGltaXQ6MjsiPgogICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoMSwwLDAsMSwtNjEwLC05MCkiPgogICAgICAgIDxnIGlkPSJibG9jay1pY29uIiB0cmFuc2Zvcm09Im1hdHJpeCgxLDAsMCwwLjk1MjM4MSwwLDMuMzMzMzMpIj4KICAgICAgICAgICAgPHJlY3QgeD0iNjEwIiB5PSI5MSIgd2lkdGg9IjQwIiBoZWlnaHQ9IjQyIiBzdHlsZT0iZmlsbDpub25lOyIvPgogICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCgxLjY4NzUsMCwwLDEuNzcxODcsNjE3LDk3LjI5OTkpIj4KICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0xNS44MTcsMC4xMTNDMTUuOTMzLDAuMjA4IDE2LDAuMzUgMTYsMC41TDE2LDE0LjVDMTYsMTQuNzM3IDE1LjgzMSwxNC45NDMgMTUuNTk4LDE0Ljk5TDEwLjU5OCwxNS45OUMxMC41MzMsMTYuMDAzIDEwLjQ2NywxNi4wMDMgMTAuNDAyLDE1Ljk5TDUuNSwxNS4wMUwwLjU5OCwxNS45OUMwLjU2NiwxNS45OTYgMC41MzMsMTYgMC41LDE2QzAuMjI2LDE2IDAsMTUuNzc0IDAsMTUuNUwwLDEuNUMwLDEuMjYzIDAuMTY5LDEuMDU3IDAuNDAyLDEuMDFMNS40MDIsMC4wMUM1LjQ2NywtMC4wMDMgNS41MzMsLTAuMDAzIDUuNTk4LDAuMDFMMTAuNSwwLjk5TDE1LjQwMiwwLjAxQzE1LjU0OSwtMC4wMiAxNS43MDEsMC4wMTggMTUuODE3LDAuMTEzWk0xMCwxLjkxTDYsMS4xMUw2LDE0LjA5TDEwLDE0Ljg5TDEwLDEuOTFaTTExLDE0Ljg5TDE1LDE0LjA5TDE1LDEuMTFMMTEsMS45MUwxMSwxNC44OVpNNSwxNC4wOUw1LDEuMTFMMSwxLjkxTDEsMTQuODlMNSwxNC4wOVoiIHN0eWxlPSJmaWxsOndoaXRlOyIvPgogICAgICAgICAgICA8L2c+CiAgICAgICAgPC9nPgogICAgPC9nPgo8L3N2Zz4K';

/**
 * Icon svg to be displayed in the category menu, encoded as a data URI.
 * @type {string}
 */
// eslint-disable-next-line max-len
const menuIconURI = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+Cjxzdmcgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgdmlld0JveD0iMCAwIDQwIDQwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHhtbDpzcGFjZT0icHJlc2VydmUiIHhtbG5zOnNlcmlmPSJodHRwOi8vd3d3LnNlcmlmLmNvbS8iIHN0eWxlPSJmaWxsLXJ1bGU6ZXZlbm9kZDtjbGlwLXJ1bGU6ZXZlbm9kZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLW1pdGVybGltaXQ6MjsiPgogICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoMSwwLDAsMSwtNjEwLC0xNDApIj4KICAgICAgICA8ZyBpZD0ibWVudS1pY29uIiB0cmFuc2Zvcm09Im1hdHJpeCgxLDAsMCwwLjk1MjM4MSwwLDUzLjMzMzMpIj4KICAgICAgICAgICAgPHJlY3QgeD0iNjEwIiB5PSI5MSIgd2lkdGg9IjQwIiBoZWlnaHQ9IjQyIiBzdHlsZT0iZmlsbDpub25lOyIvPgogICAgICAgICAgICA8Y2xpcFBhdGggaWQ9Il9jbGlwMSI+CiAgICAgICAgICAgICAgICA8cmVjdCB4PSI2MTAiIHk9IjkxIiB3aWR0aD0iNDAiIGhlaWdodD0iNDIiLz4KICAgICAgICAgICAgPC9jbGlwUGF0aD4KICAgICAgICAgICAgPGcgY2xpcC1wYXRoPSJ1cmwoI19jbGlwMSkiPgogICAgICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoMSwwLDAsMS4wNSwwLC01NikiPgogICAgICAgICAgICAgICAgICAgIDxjaXJjbGUgY3g9IjYzMCIgY3k9IjE2MCIgcj0iMjAiIHN0eWxlPSJmaWxsOnJnYigyNTUsMTgxLDApOyIvPgogICAgICAgICAgICAgICAgPC9nPgogICAgICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoMS40Mzc1LDAsMCwxLjUwOTM3LDYxOC41LDk5LjkyNTEpIj4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTUuODE3LDAuMTEzQzE1LjkzMywwLjIwOCAxNiwwLjM1IDE2LDAuNUwxNiwxNC41QzE2LDE0LjczNyAxNS44MzEsMTQuOTQzIDE1LjU5OCwxNC45OUwxMC41OTgsMTUuOTlDMTAuNTMzLDE2LjAwMyAxMC40NjcsMTYuMDAzIDEwLjQwMiwxNS45OUw1LjUsMTUuMDFMMC41OTgsMTUuOTlDMC41NjYsMTUuOTk2IDAuNTMzLDE2IDAuNSwxNkMwLjIyNiwxNiAwLDE1Ljc3NCAwLDE1LjVMMCwxLjVDMCwxLjI2MyAwLjE2OSwxLjA1NyAwLjQwMiwxLjAxTDUuNDAyLDAuMDFDNS40NjcsLTAuMDAzIDUuNTMzLC0wLjAwMyA1LjU5OCwwLjAxTDEwLjUsMC45OUwxNS40MDIsMC4wMUMxNS41NDksLTAuMDIgMTUuNzAxLDAuMDE4IDE1LjgxNywwLjExM1pNMTAsMS45MUw2LDEuMTFMNiwxNC4wOUwxMCwxNC44OUwxMCwxLjkxWk0xMSwxNC44OUwxNSwxNC4wOUwxNSwxLjExTDExLDEuOTFMMTEsMTQuODlaTTUsMTQuMDlMNSwxLjExTDEsMS45MUwxLDE0Ljg5TDUsMTQuMDlaIiBzdHlsZT0iZmlsbDp3aGl0ZTsiLz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgPC9nPgogICAgICAgIDwvZz4KICAgIDwvZz4KPC9zdmc+Cg==';

/**
 * Class for the new blocks in Scratch 3.0
 * @param {Runtime} runtime - the runtime instantiating this block package.
 * @constructor
 */
class Scratch3OpenStreetMapBlocks {
    constructor (runtime) {
        /**
         * The runtime instantiating this block package.
         * @type {Runtime}
         */
        this.runtime = runtime;
        this.tileMap = new TileMap();
        this.tileCache = new TileCache();

        this.canvas = document.createElement('canvas');
        this.canvas.width = 480;
        this.canvas.height = 360;
    }

    async drawImages () {
        if (this.runtime.renderer) {
            this.ctx = this.canvas.getContext('2d');

            const promises = [];
            for (const tile of this.tileMap.tiles) {
                promises.push(this.tileCache.getImage(tile.zoom, tile.x, tile.y));
            }

            const images = [];
            for (const promise of promises) {
                images.push(await promise);
            }

            let index = 0;
            for (const tile of this.tileMap.tiles) {
                this.ctx.drawImage(images[index], tile.screenX, tile.screenY);
                ++index;
            }

            // Scratch固有の処理
            this.skinId = this.runtime.renderer.createBitmapSkin(this.canvas, 1);
            const drawableId = this.runtime.renderer.createDrawable(
                StageLayering.BACKGROUND_LAYER
            );
            this.runtime.renderer.updateDrawableProperties(drawableId, {
                skinId: this.skinId
            });
            // ここまで
        }
    }

    drawTileMap (args) {
        const latitude = Cast.toNumber(args.LATITUDE);
        const longitude = Cast.toNumber(args.LONGITUDE);
        const zoom = Cast.toNumber(args.ZOOM);

        this.tileMap.buildTiles(zoom, longitude, latitude, 480, 360);
        this.drawImages();
    }

    /**
     * @returns {object} metadata for this extension and its blocks.
     */
    getInfo () {
        return {
            id: 'openStreetMap',
            name: '地図',
            menuIconURI: menuIconURI,
            blockIconURI: blockIconURI,
            blocks: [
                {
                    opcode: 'drawTileMap',
                    text: '緯度 [LATITUDE], 経度 [LONGITUDE]の地図をズームレベル[ZOOM]で表示する',
                    blockType: BlockType.COMMAND,
                    arguments: {
                        ZOOM: {
                            type: ArgumentType.NUMBER,
                            defaultValue: 18
                        },
                        LATITUDE: {
                            type: ArgumentType.NUMBER,
                            defaultValue: 35.689185
                        },
                        LONGITUDE: {
                            type: ArgumentType.NUMBER,
                            defaultValue: 139.691648
                        }
                    }
                }
            ],
            menus: {
            }
        };
    }
}

module.exports = Scratch3OpenStreetMapBlocks;

index.jsのポイントは、以下のScratchの描画エンジンを使うところで、この部分はタコキンのPスクール・ブログの解説を参考にさせていただきました(ありがとうございますございます🙇)。

            // Scratch固有の処理
            this.skinId = this.runtime.renderer.createBitmapSkin(this.canvas, 1);
            const drawableId = this.runtime.renderer.createDrawable(
                StageLayering.BACKGROUND_LAYER
            );
            this.runtime.renderer.updateDrawableProperties(drawableId, {
                skinId: this.skinId
            });
            // ここまで

tile-map.js

class TileMap {
    constructor () {
        this.tileSize = 256;
        this.xCount = 0;
        this.yCount = 0;
        this.tiles = [];
    }

    // 経度→x変換
    longitude2x (lon, zoom) {
        return (Math.floor((lon + 180) / 360 * Math.pow(2, zoom)));
    }

    // 緯度→y変換
    latitude2y (lat, zoom) {
        const n = lat * Math.PI / 180;
        return (Math.floor((1 - (Math.log(Math.tan(n) + (1 / Math.cos(n))) / Math.PI)) / 2 * Math.pow(2, zoom)));
    }

    // x→経度変換
    x2longitude (x, zoom) {
        return (x / Math.pow(2, zoom) * 360) - 180;
    }

    // y→緯度変換
    y2latitude (y, zoom) {
        const n = Math.PI - (2 * Math.PI * y / Math.pow(2, zoom));
        return (180 / Math.PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))));
    }

    // x, yをcountからはみ出ている場合に補正
    adjust (index, count) {
        // 計算例
        // count = 1の時は常に0
        // count = 2, index = -1 の時は1を返す
        // count = 2, index = -2 の時は0を返す
        // count = 2, index = 2 の時は0を返す
        // count = 2, index = 3 の時は1を返す
        if (count === 1) {
            return 0;
        } else if (index >= 0 && index < count) {
            return index;
        }
        const remainder = index % count;

        return (index < 0) ? count + remainder : remainder;
    }

    // screenWidth, screenHeightを埋めるタイルを計算しtilesに格納する。
    buildTiles (zoom, centerLongitude, centerLatitude, screenWidth, screenHeight) {
        zoom = Math.round(zoom);
        if (zoom < 0) zoom = 0;
        if (zoom > 18) zoom = 18;

        // 画面の1/2に敷き詰めるタイル数を計算
        const wHalfCount = Math.ceil(screenWidth / (2 * this.tileSize));
        const hHalfCount = Math.ceil(screenHeight / (2 * this.tileSize));

        // 真ん中のタイルのx, yを求める
        const xc = this.longitude2x(centerLongitude, zoom);
        const yc = this.latitude2y(centerLatitude, zoom);

        // 画面に表示するタイルの範囲を取得
        const yMin = yc - hHalfCount;
        const yMax = yc + hHalfCount;
        const xMin = xc - wHalfCount;
        const xMax = xc + wHalfCount;

        // タイル行列の列数、行数を計算
        this.xCount = xMax - xMin + 1;
        this.yCount = yMax - yMin + 1;
        this.tiles = [];

        // 真ん中のタイルの左上のWold座標を求める
        const lng0 = this.x2longitude(xc, zoom);
        const lat0 = this.y2latitude(yc, zoom);

        // 真ん中のタイルの右下のWorld座標を求める
        const lng1 = this.x2longitude(xc + 1, zoom);
        const lat1 = this.y2latitude(yc + 1, zoom);

        // センタータイルの表示位置の左上からのオフセット(画面座標系)を計算
        const xOffset = this.tileSize * (centerLongitude - lng0) / (lng1 - lng0);
        const yOffset = this.tileSize * (centerLatitude - lat0) / (lat1 - lat0);

        const count = 2 ** zoom;
        // 画面に表示するタイルを列挙
        let n = 0;
        for (let y = yMin; y <= yMax; ++y) {
            let m = 0;
            for (let x = xMin; x <= xMax; ++x) {
                // 画面上のx座標を計算
                const screenX = -(this.xCount * this.tileSize / 2) +
                (m * this.tileSize) + (this.tileSize / 2) - xOffset + (screenWidth / 2);
                // 画面上のy座標を計算
                const screenY = -(this.yCount * this.tileSize / 2) +
                (n * this.tileSize) + (this.tileSize / 2) - yOffset + (screenHeight / 2);
                this.tiles.push({zoom, x: this.adjust(x, count), y: this.adjust(y, count), screenX, screenY});
                ++m;
            }
            ++n;
        }
    }
}

module.exports = TileMap;

tile-cache.js

class TileCache {
    constructor () {
        this.cache = new Map();
    }

    async getImage (zoom, x, y) {
        const key = `${zoom}-${x}-${y}`;
        const cachedImage = this.cache.get(key);
        if (cachedImage) {
            return cachedImage;
        }

        const img = await this.loadImage(zoom, x, y);
        // キャッシュする画像は100まで
        if (this.cache.size >= 100) {
            const deleteKey = this.cache.keys().next().value;
            this.cache.delete(deleteKey);
        }
        this.cache.set(key, img);
        return img;
    }

    loadImage (zoom, x, y) {
        const prefix = 'https://a.tile.openstreetmap.org';
        const suffix = '.png';

        const url = `${prefix}/${zoom}/${x}/${y}${suffix}`;
        return new Promise((resolve, reject) => {
            const img = new Image();
            img.onload = () => resolve(img);
            img.onerror = e => reject(e);
            img.src = url;
            img.crossOrigin = 'Anonymous';
        });
    }
}

module.exports = TileCache;

extension-manager.jsへの登録

上記のコードをScratch-vmに登録します。src/extension-support/extension-manager.jsを以下のように修正します。

...中略
const builtinExtensions = {
    // This is an example that isn't loaded with the other core blocks,
    // but serves as a reference for loading core blocks as extensions.
    coreExample: () => require('../blocks/scratch3_core_example'),
    // These are the non-core built-in extensions.
    pen: () => require('../extensions/scratch3_pen'),
    wedo2: () => require('../extensions/scratch3_wedo2'),
    music: () => require('../extensions/scratch3_music'),
    microbit: () => require('../extensions/scratch3_microbit'),
    text2speech: () => require('../extensions/scratch3_text2speech'),
    translate: () => require('../extensions/scratch3_translate'),
    videoSensing: () => require('../extensions/scratch3_video_sensing'),
    ev3: () => require('../extensions/scratch3_ev3'),
    makeymakey: () => require('../extensions/scratch3_makeymakey'),
    boost: () => require('../extensions/scratch3_boost'),
    gdxfor: () => require('../extensions/scratch3_gdx_for'),
    openMeteo: () => require('../extensions/scratch3_open_meteo'),
    openStreetMap: () => require('../extensions/scratch3_open_street_map') // <- この行を追加
};
...中略

テスト〜GitHub Pagesでの公開

(1)天気予報を取得と同じですので参照下さい。

おわりに

完成してみると比較的シンプルな実装にまとまりましたが、ここまで来るのにだいぶ試行錯誤しました😅。Javascriptなので、最初はleafletで実装しようと思いましたが、、、Scratchの描画エンジンを使いたかったので、最終的にはOpen Street Map APIから画像を取得して表示する、今回の実装方法に落ち着きました。

地図はピン表示など色々応用が考えられそうですので、今回の実装を元に更にブラッシュアップしていきたいと思います!

GitHubで編集を提案

Discussion