am2222 / mapbox-pmtiles

A custom source to add PmTiles support to mapbox gl js. Supports both raster and vector pmtiles
https://am2222.github.io/mapbox-pmtiles/
11 stars 1 forks source link

Using JS instead of TS #22

Open hbd9417 opened 1 month ago

hbd9417 commented 1 month ago

Hello! I'm new to coding but tried to convert it for using in a NextJS project without TS. I changed also the example use a local pmtile raster file and I'm not getting any error but is also not working. Could you please give me any help? Thanks!

// Mapbox import
import mapboxgl from "mapbox-gl";
import { PMTiles, Protocol, TileType } from "pmtiles";

// @ts-expect-error
const VectorTileSourceImpl = mapboxgl.Style.getSourceType("vector");

export const SOURCE_TYPE = "pmtile-source";
/**
 * Extends an object with another one
 * @param dest the destination object
 * @param sources the source objects
 * @returns an object with all the keys from both dest and sources
 */
const extend = (dest, ...sources) => {
    for (const src of sources) {
        for (const k in src) {
            dest[k] = src[k];
        }
    }
    return dest;
}

const mercatorXFromLng = (lng) => {
    return (180 + lng) / 360;
}

const mercatorYFromLat = (lat) => {
    return (
        (180 -
            (180 / Math.PI) *
            Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360))) /
        360
    );
}

class TileBounds {
    constructor(bounds, minzoom, maxzoom) {
        this.bounds = mapboxgl.LngLatBounds.convert(this.validateBounds(bounds));
        this.minzoom = minzoom || 0;
        this.maxzoom = maxzoom || 24;
    }

    validateBounds(bounds) {
        // make sure the bounds property contains valid longitude and latitudes
        if (!Array.isArray(bounds) || bounds.length !== 4)
            return [-180, -90, 180, 90];
        return [
            Math.max(-180, bounds[0]),
            Math.max(-90, bounds[1]),
            Math.min(180, bounds[2]),
            Math.min(90, bounds[3]),
        ];
    }

    contains(tileID) {
        const worldSize = Math.pow(2, tileID.z);
        const level = {
            minX: Math.floor(mercatorXFromLng(this.bounds.getWest()) * worldSize),
            minY: Math.floor(mercatorYFromLat(this.bounds.getNorth()) * worldSize),
            maxX: Math.ceil(mercatorXFromLng(this.bounds.getEast()) * worldSize),
            maxY: Math.ceil(mercatorYFromLat(this.bounds.getSouth()) * worldSize),
        };
        const hit =
            tileID.x >= level.minX &&
            tileID.x < level.maxX &&
            tileID.y >= level.minY &&
            tileID.y < level.maxY;
        return hit;
    }
}

class Event {
    constructor(type, data = {}) {
        extend(this, data);
        this.type = type;
    }
}
class ErrorEvent extends Event {
    constructor(error, data = {}) {
        super('error', extend({ error }, data));
        this.error = error;
    }
}

/**
 * The PmTiles source. It mainly should work as a regular source as other mapbox sources.
 * @public
 * @remarks
 * The Source will automatically set its type [vector|raster] based on the type defined in the pmTiles metadata. The different PmTiles
 * data type is defined as here: {@link https://github.com/protomaps/PMTiles/blob/main/spec/v3/spec.md#tile-type-tt}. We also use the
 * rest of the headers to set source boundary. This includes `minZoom`, `maxZoom`, `minLon`, `minLat`, `maxLon` and `maxLat`  if they are
 * available.
 *
 * @example In order to use PmTiles source you need to define the source as a custom source to them map. this should only happen once
 * ```js
 * import mapboxgl from "mapbox-gl";
 *
 * import { PmTilesSource } from "mapbox-pmtiles";
 * //Define custom source
 * mapboxgl.Style.setSourceType(PmTilesSource.SOURCE_TYPE, PmTilesSource);
 *
 * map.on("load", () => {
 *
 * const PMTILES_URL =
 *    "https://r2-public.protomaps.com/protomaps-sample-datasets/protomaps-basemap-opensource-20230408.pmtiles";
 *
 *     map.addSource("pmTileSourceName", {
 *     type: PmTilesSource.SOURCE_TYPE, //Add this line
 *     url: PMTILES_URL,
 *     maxzoom: 10,
 *     });
 *
 *     map.current.showTileBoundaries = true;
 *     map.current.addLayer({
 *         id: "places",
 *         source: "pmTileSourceName",
 *         "source-layer": "places",
 *         type: "circle",
 *         paint: {
 *             "circle-color": "steelblue",
 *         },
 *         maxzoom: 14,
 *     });
 * });
 *
 * ```
 */
export class PmTilesSource extends VectorTileSourceImpl {
    static SOURCE_TYPE = SOURCE_TYPE

    constructor(id, options, _dispatcher, _eventedParent) {
        super(...[id, options, _dispatcher, _eventedParent]);

        this.id = id;
        this._dataType = 'vector';
        this.dispatcher = _dispatcher;
        this._implementation = options;
        if (!this._implementation) {
            this.fire(new ErrorEvent(new Error(`Missing options for ${this.id} ${SOURCE_TYPE} source`)));
        }

        const { url } = options;

        this.reparseOverscaled = true;
        this.scheme = 'xyz';
        this.tileSize = 512;
        this._loaded = false;
        this.type = 'vector';

        this._protocol = new Protocol();
        this.tiles = [`pmtiles://${url}/{z}/{x}/{y}`]
        const pmtilesInstance = new PMTiles(url);

        // this is so we share one instance across the JS code and the map renderer
        this._protocol.add(pmtilesInstance);
        this._instance = pmtilesInstance;

    }

    static async getMetadata(url) {
        const instance = new PMTiles(url);
        return instance.getMetadata()
    }

    static async getHeader(url) {
        const instance = new PMTiles(url);
        return instance.getHeader()
    }

    getExtent() {
        if (!this.header) return [[-180, -90], [180, 90]]

        const { minZoom, maxZoom, minLon, minLat, maxLon, maxLat, centerZoom, centerLon, centerLat } = this.header

        return [minLon, minLat, maxLon, maxLat]
    }

    hasTile(tileID) {
        return !this.tileBounds || this.tileBounds.contains(tileID.canonical);
    }

    async load(callback) {
        this._loaded = false;
        this.fire(new Event("dataloading", { dataType: "source" }));

        return Promise.all([this._instance.getHeader(), this._instance.getMetadata()]).then(([header, tileJSON]) => {
            extend(this, tileJSON);

            this.header = header;
            const { specVersion, clustered, tileType, minZoom, maxZoom, minLon, minLat, maxLon, maxLat, centerZoom, centerLon, centerLat } = header

            const requiredVariables = [minZoom, maxZoom, minLon, minLat, maxLon, maxLat]

            if (!requiredVariables.includes(undefined) && !requiredVariables.includes(null)) {
                this.tileBounds = new TileBounds(
                    [minLon, minLat, maxLon, maxLat],
                    minZoom,
                    maxZoom
                );
                this.minzoom = minZoom
                this.maxzoom = maxZoom
            }

            if (this.maxzoom == undefined) {
                console.warn('The maxzoom parameter is not defined in the source json. This can cause memory leak. So make sure to define maxzoom in the layer')
            }

            this.minzoom = Number.parseInt(this.minzoom.toString()) || 0;
            this.maxzoom = Number.parseInt(this.maxzoom.toString()) || 0;

            this._loaded = true;

            this.tileType = tileType

            switch (tileType) {
                case TileType.Png:
                    this.contentType = 'image/png';
                    break;
                case TileType.Jpeg:
                    this.contentType = 'image/jpeg';
                    break;
                case TileType.Webp:
                    this.contentType = 'image/webp';
                    break;
                case TileType.Avif:
                    this.contentType = 'image/avif';
                    break;
                case TileType.Mvt:
                    this.contentType = 'application/vnd.mapbox-vector-tile';
                    break;
            }

            if ([TileType.Jpeg, TileType.Png].includes(this.tileType)) {
                this.loadTile = this.loadRasterTile
                this.type = 'raster';
            } else if (this.tileType === TileType.Mvt) {
                this.loadTile = this.loadVectorTile
                this.type = 'vector';
            } else {
                this.fire(new ErrorEvent(new Error("Unsupported Tile Type")));
            }

            this.fire(
                new Event("data", { dataType: "source", sourceDataType: "metadata" })
            );
            this.fire(
                new Event("data", { dataType: "source", sourceDataType: "content" })
            );
        }).catch((err) => {
            this.fire(new ErrorEvent(err));
            if (callback) callback(err);
        });
    }

    loaded() {
        return this._loaded;
    }

    loadVectorTile(tile, callback) {
        const done = (err, data) => {
            delete tile.request;

            if (tile.aborted) return callback(null);

            if (err && (err).status !== 404) {
                return callback(err);
            }

            if (data && data.resourceTiming)
                tile.resourceTiming = data.resourceTiming;

            if (this.map?._refreshExpiredTiles && data) tile.setExpiryData(data);
            tile.loadVectorData(data, this.map?.painter);

            callback(null);

            if (tile.reloadCallback) {
                this.loadVectorTile(tile, tile.reloadCallback);
                tile.reloadCallback = null;
            }
        }

        const url = this.map?._requestManager.normalizeTileURL(
            tile.tileID.canonical.url(this.tiles, this.scheme)
        );
        const request = this.map?._requestManager.transformRequest(url, "Tile");

        const params = {
            request,
            data: {},
            uid: tile.uid,
            tileID: tile.tileID,
            tileZoom: tile.tileZoom,
            zoom: tile.tileID.overscaledZ,
            tileSize: this.tileSize * tile.tileID.overscaleFactor(),
            type: "vector",
            source: this.id,
            scope: this.scope,
            showCollisionBoxes: this.map?.showCollisionBoxes,
            promoteId: this.promoteId,
            isSymbolTile: tile.isSymbolTile,
            extraShadowCaster: tile.isExtraShadowCaster,
        };

        const afterLoad = (error, data, cacheControl, expires) => {
            if (error || !data) {
                done.call(this, error);
                return
            }

            params.data = {
                cacheControl: cacheControl,
                expires: expires,
                rawData: data,
            };
            if (this.map._refreshExpiredTiles) tile.setExpiryData({ cacheControl, expires });
            if (tile.actor)
                tile.actor.send(
                    "loadTile",
                    params,
                    done.bind(this),
                    undefined,
                    true
                );
        };

        if (!tile.actor || tile.state === "expired") {
            tile.actor = this._tileWorkers[url] = this._tileWorkers[url] || this.dispatcher.getActor();

            tile.request = this._protocol.tile({ ...request }, afterLoad);
        } else if (tile.state === "loading") {
            tile.reloadCallback = callback;
        } else {
            tile.request = this._protocol.tile({ ...tile, url }, afterLoad);
        }
    }

    loadRasterTileData(tile, data) {
        tile.setTexture(data, this.map.painter);
    }

    loadRasterTile(tile, callback) {
        const done = ({ data, cacheControl, expires }) => {
            delete tile.request;

            if (tile.aborted) return callback(null);

            if (data === null || data === undefined) {
                const emptyImage = { width: this.tileSize, height: this.tileSize, data: null };
                this.loadRasterTileData(tile, (emptyImage));
                tile.state = 'loaded';
                return callback(null);
            }

            if (data && data.resourceTiming)
                tile.resourceTiming = data.resourceTiming;

            if (this.map._refreshExpiredTiles) tile.setExpiryData({ cacheControl, expires });

            const blob = new window.Blob([new Uint8Array(data)], { type: 'image/png' });
            window.createImageBitmap(blob).then((imageBitmap) => {
                this.loadRasterTileData(tile, imageBitmap);
                tile.state = 'loaded';
                callback(null);
            }).catch((error) => {
                tile.state = 'errored';
                return callback(new Error(`Can't infer data type for ${this.id}, only raster data supported at the moment. ${error}`));
            })

        }

        const url = this.map?._requestManager.normalizeTileURL(
            tile.tileID.canonical.url(this.tiles, this.scheme)
        );
        const request = this.map?._requestManager.transformRequest(url, "Tile");

        const controller = new AbortController();
        tile.request = { cancel: () => controller.abort() };
        this._protocol.tile(request, controller).then(done.bind(this))
            .catch((error) => {
                if (error.code === 20) return;
                tile.state = 'errored';
                callback(error);
            });
    }
}

export default PmTilesSource

Here's my code to plot a local pmtile (I can read all console.log and the data is correct):

const addLocalLayer = async () => {
    const map = mapRef.current?.getMap();
    const layerId = 'localLayer';
    const sourceId = 'localSource';

    if (map) {
      const PMTILES_URL = './local.pmtiles';

      console.log('PMTILES_URL', PMTILES_URL);

      try {
        const header = await PmTilesSource.getHeader(PMTILES_URL);
        const bounds = [
          header.minLon,
          header.minLat,
          header.maxLon,
          header.maxLat,
        ];

        console.log('header', header);
        console.log('bounds', bounds);

        const source = {
          type: PmTilesSource.SOURCE_TYPE,
          url: PMTILES_URL,
          minzoom: header.minZoom,
          maxzoom: header.maxZoom,
          bounds: bounds,
        };

        console.log('source', source);

        const layer = {
          id: layerId,
          type: 'raster',
          source: sourceId,
          'source-layer': layerId,
          maxzoom: header.maxZoom,
        };

        console.log('layer', layer);

        if (!map.getSource(sourceId)) {
          map.addSource(sourceId, source);
        }

        if (!map.getLayer(layerId)) {
          map.addLayer(layer);
        }
      } catch (error) {
        console.error('Erro ao adicionar camada PMTiles:', error);
      }
    }
  };
am2222 commented 1 month ago

Hi @hbd9417 May I ask why are you using the source code? It makes it difficult for you to maintain the possible bug fixes and such in future.

Id use this snippet in my code. It work without need to install ts or such

import mapboxgl from "mapbox-gl";

import { PmTilesSource } from "mapbox-pmtiles";
//Define custom source
mapboxgl.Style.setSourceType(PmTilesSource.SOURCE_TYPE, PmTilesSource);

map.on("load", () => {

    const PMTILES_URL =
    "https://r2-public.protomaps.com/protomaps-sample-datasets/protomaps-basemap-opensource-20230408.pmtiles";

    const header = await mapboxPmTiles.PmTilesSource.getHeader(PMTILES_URL);
    const bounds = [
          header.minLon,
          header.minLat,
          header.maxLon,
          header.maxLat,
        ];

    map.addSource('pmTileSourceName', {
          type: mapboxPmTiles.PmTilesSource.SOURCE_TYPE,
          url: PMTILES_URL,
          minzoom: header.minZoom,
          maxzoom: header.maxZoom,
          bounds: bounds,
        });

    map.current.showTileBoundaries = true;
    map.current.addLayer({
        id: "places",
        source: "pmTileSourceName",
        "source-layer": "places",
        type: "circle",
        paint: {
            "circle-color": "steelblue",
        },
        maxzoom: 14,
    });
});
hbd9417 commented 1 month ago

Thanks for the reply! When I use the code as it is, I get the error below:

./node_modules/mapbox-pmtiles/src/index.ts
Module parse failed: Unexpected token (6:26)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
| 
| // @ts-expect-error
> const VectorTileSourceImpl: Class<VectorTileSource> = mapboxgl.Style.getSourceType<VectorTileSource>("vector");
| 
| export const SOURCE_TYPE = "pmtile-source";
am2222 commented 1 month ago

May I have your webpack config file? I think the configuration of webpack should be modified.

Are you able to import other packages without any issue?

hbd9417 commented 1 month ago

Yes, I'm able to import other packages without issues.

Here's the webpack config file (next.config), actually I don't have any custom configuration for it:

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: false,
  compiler: {
    removeConsole: false,
  },
};

module.exports = nextConfig;
am2222 commented 1 month ago

@hbd9417 I need to investigate it more. I think it should be a fix from this package to allow you to use it in webpack project. Do you by any chance have a small project that can share with me so I can test it more to see if I can push a fix for it?

hbd9417 commented 1 month ago

Sure! I made a small project here for you to test. What's the best way for me to send you? Thank you!

am2222 commented 2 weeks ago

Hi @hbd9417 sorry for late reply. can you share it via https://stackblitz.com please?