androidnode.jsionic-frameworkmapswebserver

How can I use offline maps in an ionic app


I have an ionic angular app that uses online maps (OSM and Spanish IGN maps). It runs on Android (at the moment there is not an ios versión). Now, I need to develop the possibility of using offline maps. To do it I generated an .mbtiles file with OSM vector tiles of my área of interest (plus a number of low-zoom tiles of the rest of the world, just in case). The file size is around 200 MB and I host it in the internal storage of my Android device.

I can generate vector tiles from the .mbtiles file using SQLite. Then, as far as I know, I need a web server that can serve dynamic contents to the maps component of my app. And this is the point, I suspect that I must do it outside of my ionic app, maybe through nodejs.

So, I can have a web server and a separate ionic app. How can I start both together: I suspect I can not launch the web server from ionic: I suppose (I am not experienced with backend developent) I have to do it from nodejs.

I would appreciate any help on it.


Solution

  • Obviously it is possible to set up offline maps using a node.js web server to provide tiles from an .mbtiles file. However, I managed to set it up without any server. I used @capacitor-community/sqlite to extract tiles and serve them to openLayers. My code is

    --- map.page.ts ----

    async createMap() {
    (...)
    case 'offline':
      credits = '© MapTiler © OpenStreetMap contributors'
      await this.server.openMbtiles('offline.mbtiles');
      const olSource = await this.createSource();
      if (!olSource) return;
      olLayer = new VectorTileLayer({ source: olSource, style: vectorTileStyle });
    break;
    (...)
    // Create map
    this.map = new Map({
      target: 'map',
      layers: [olLayer, this.currentLayer, this.archivedLayer, this.multiLayer],
      view: new View({ center: currentPosition, zoom: 9 }),
      controls: [new Zoom(), new ScaleLine(), new Rotate(), new CustomControl(this.fs)],
    });
    (...)
    
    createSource() {
      try {
        // Create vector tile source
        return new VectorTileSource({
          format: new MVT(),
          tileClass: VectorTile,
          tileGrid: new TileGrid({
            extent: [-20037508.34, -20037508.34, 20037508.34, 20037508.34],
            resolutions: Array.from({ length: 20 }, (_, z) => 156543.03392804097 / Math.pow(2, z)),
            tileSize: [256, 256],
          }),
          // Tile load function
          tileLoadFunction: async (tile) => {
            const vectorTile = tile as VectorTile;
            const [z, x, y] = vectorTile.getTileCoord();
            try {
              // Get vector tile
              const rawData = await this.server.getVectorTile(z, x, y);
              if (!rawData?.byteLength) {
                vectorTile.setLoader(() => {});
                vectorTile.setState(TileState.EMPTY);
                return;
              }
              // Decompress
              const decompressed = pako.inflate(new Uint8Array(rawData));
              // Read features
              const features = new MVT().readFeatures(decompressed, {
                extent: vectorTile.extent ?? [-20037508.34, -20037508.34, 20037508.34, 20037508.34],
                featureProjection: 'EPSG:3857',
              });
              // Set features to vector tile
              vectorTile.setFeatures(features);
            } catch (error) {
              vectorTile.setState(TileState.ERROR);
            }
          },
          tileUrlFunction: ([z, x, y]) => `${z}/${x}/${y}`,
        });
      } catch (e) {
        console.error('Error in createSource:', e);
        return null;
      }
    }
    

    ---- server.service.ts -----

    async getVectorTile(zoom: number, x: number, y: number): Promise<ArrayBuffer | null> {
      console.log(`🔍 Trying to get vector tile z=${zoom}, x=${x}, y=${y}`);
      if (!this.db) {
        console.error('❌ Database connection is not open.');
        return null;
      }
      // Query the database for the tile using XYZ coordinates
      const resultXYZ = await this.db.query(
        `SELECT tile_data FROM tiles WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?;`,
        [zoom, x, y]  
      );
      if (resultXYZ?.values?.length) {
        console.log(`✅ Tile found: z=${zoom}, x=${x}, y=${y}`);
        const tileData = resultXYZ.values[0].tile_data;
        // Ensure tileData is returned as an ArrayBuffer
        if (tileData instanceof ArrayBuffer) {
          return tileData;
        } else if (Array.isArray(tileData)) {
          return new Uint8Array(tileData).buffer; // Convert array to ArrayBuffer
        } else {
          console.error(`❌ Unexpected tile_data format for ${zoom}/${x}/${y}`, tileData);
          return null;
        }
      } else {
        console.log(`❌ No tile found: z=${zoom}, x=${x}, y=${y}`);
        return null;
      }
    }