npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

pdf-mapview

v0.5.0

Published

React viewer and ingest toolkit for map-like PDF, floorplan, and large image experiences.

Readme

pdf-mapview

pdf-mapview is a React viewer and ingest toolkit for turning large PDFs, floorplans, and images into smooth, map-like experiences with static tiles, manifests, and normalized overlays.

What it ships

  • pdf-mapview — shared types, manifest helpers, schemas (browser + server safe)
  • pdf-mapview/client — React viewer runtime (browser only)
  • pdf-mapview/native — Expo / React Native tile viewer runtime for prebuilt manifests
  • pdf-mapview/ingest — Node ingest APIs, storage adapters, CLI
  • pdf-mapview/server — server-safe re-export of the ingest toolkit (use from TanStack Start server functions, Next.js route handlers, etc.)

This package is not a hosted service. You can generate static tiles locally, upload them anywhere, or plug in a custom storage adapter.

The ingest pipeline is pure Node and uses prebuilt npm modules. PDF pages are rasterized with pdfjs-dist plus @napi-rs/canvas, and image normalization, resizing, tile generation, and preview generation are handled by sharp. There is no required system CLI or hosted backend.

Install

npm install pdf-mapview react react-dom

Expo / React Native tile viewer

Mobile apps should render prebuilt tile manifests produced by pdf-mapview/ingest or pdf-mapview/server. The native runtime does not ingest PDFs, rasterize pages, or generate tiles on-device.

npx expo install @shopify/react-native-skia react-native-gesture-handler react-native-reanimated react-native-worklets
import { useRef } from "react";
import { TileMapNative, type NativeMapApi } from "pdf-mapview/native";

export function FloorPlanScreen({ manifest }: { manifest: any }) {
  const mapRef = useRef<NativeMapApi>(null);

  return (
    <TileMapNative
      ref={mapRef}
      source={{
        type: "tiles",
        manifest,
        baseUrl: "https://cdn.example.com/maps/site-plan-001/",
      }}
      onRegionClick={(region) => {
        mapRef.current?.zoomToRegion(region.id);
      }}
      style={{ flex: 1 }}
    />
  );
}

pdf-mapview/native supports tile sources only. Passing raw pdf or image sources throws a clear error so mobile apps stay on the server-generated tile path. Signed or short-lived tile URLs are supported with the same getTileUrl hook used by the web viewer.

Viewer usage

Tile source

import { TileMapViewer } from "pdf-mapview/client";

function Floorplan({ manifest }: { manifest: any }) {
  return (
    <div style={{ height: 720 }}>
      <TileMapViewer
        source={{
          type: "tiles",
          manifest,
          baseUrl: "/maps/site-plan-001",
        }}
      />
    </div>
  );
}

Viewer CORS options

<TileMapViewer
  source={{
    type: "tiles",
    manifest,
    baseUrl: "https://cdn.example.com/maps/site-plan-001",
  }}
  openSeadragon={{
    crossOriginPolicy: "Anonymous",
    ajaxWithCredentials: false,
  }}
/>

Use crossOriginPolicy: "Anonymous" for public CDN or static-hosted assets that send Access-Control-Allow-Origin. Use crossOriginPolicy: "use-credentials" together with ajaxWithCredentials: true only when the remote host requires cookies or credentialed CORS.

Disabling drag momentum

By default OpenSeadragon applies velocity decay after a quick drag-release so the view "flicks" across the screen. Set flickEnabled: false to disable that momentum for mouse, touch, and pen at once:

<TileMapViewer
  source={{ type: "tiles", manifest }}
  openSeadragon={{ flickEnabled: false }}
/>

For finer control you can override individual gesture settings per input device. Explicit gestureSettingsMouse / gestureSettingsTouch / gestureSettingsPen entries win over the flickEnabled shortcut:

<TileMapViewer
  source={{ type: "tiles", manifest }}
  openSeadragon={{
    flickEnabled: false,
    gestureSettingsMouse: { flickEnabled: true }, // keep flick on mouse only
  }}
/>

Signed tile URLs

Pass getTileUrl on the tile source to compute each tile URL at request time — useful for signed S3 URLs, short-lived CDN tokens, or any per-tile authorization:

<TileMapViewer
  source={{
    type: "tiles",
    manifest,
    async getTileUrl({ z, x, y, signal }) {
      const res = await fetch(`/api/sign-tile?z=${z}&x=${x}&y=${y}`, { signal });
      const { url } = await res.json();
      return url;
    },
  }}
/>

getTileUrl overrides baseUrl. The signal is an AbortSignal the viewer uses when a tile request is no longer needed.

Image source

<TileMapViewer
  source={{
    type: "image",
    src: "/floorplan.png",
    width: 8000,
    height: 6000,
  }}
/>

PDF fallback source

import { pdfWorkerUrl } from "pdf-mapview/web-worker";

<TileMapViewer
  source={{
    type: "pdf",
    file: "/plan.pdf",
    page: 1,
    workerSrc: pdfWorkerUrl,
  }}
/>

pdfWorkerUrl resolves to the pdf.worker.min.mjs file bundled in the package. See PDF worker hosting if your bundler strips it.

Regions

const regions = [
  {
    id: "suite-a",
    label: "Suite A",
    geometry: {
      type: "rectangle",
      rect: { x: 0.1, y: 0.2, width: 0.15, height: 0.12 },
    },
  },
];

<TileMapViewer
  source={{ type: "tiles", manifest }}
  regions={regions}
  onRegionClick={(region) => console.log(region.id)}
/>

Ingest usage

Local output

import { ingestPdf, localStorageAdapter } from "pdf-mapview/ingest";

const result = await ingestPdf({
  input: "./plans/site-plan.pdf",
  page: 1,
  id: "site-plan-001",
  storage: localStorageAdapter({
    baseDir: "./public/maps/site-plan-001",
    clean: true,
  }),
});

Default PDF ingest behavior

const result = await ingestPdf({
  input: "./plans/site-plan.pdf",
  page: 1,
  id: "site-plan-001",
  storage: localStorageAdapter({
    baseDir: "./public/maps/site-plan-001",
    clean: true,
  }),
});

When rasterDpi is omitted, PDF ingest preserves the existing maxDimension-based scaling behavior. The generated manifest still records the effective raster DPI in manifest.source.rasterization.

PDF ingest with custom DPI

const result = await ingestPdf({
  input: "./plans/site-plan.pdf",
  page: 1,
  id: "site-plan-001-300dpi",
  rasterDpi: 300,
  storage: localStorageAdapter({
    baseDir: "./public/maps/site-plan-001-300dpi",
    clean: true,
  }),
});

Higher DPI is useful when you need sharper text, linework, or annotation detail before tiling. The tradeoff is more pixels to rasterize, more memory and CPU during ingest, and potentially more output tiles.

In-memory / custom upload flow

import { ingestImage, memoryStorageAdapter } from "pdf-mapview/ingest";

const result = await ingestImage({
  input: imageBuffer,
  id: "floor-02",
  storage: memoryStorageAdapter(),
});

S3-compatible storage

import { ingestPdf, s3CompatibleStorageAdapter } from "pdf-mapview/ingest";

const storage = s3CompatibleStorageAdapter({
  prefix: "maps/site-plan-001",
  baseUrl: "https://cdn.example.com",
  async putObject({ key, body, contentType, cacheControl }) {
    await myObjectStore.put(key, body, { contentType, cacheControl });
    return { url: `https://cdn.example.com/${key}` };
  },
});

const result = await ingestPdf({
  input: "./plans/site-plan.pdf",
  id: "site-plan-001",
  storage,
  writeConcurrency: 16,
  retainFilesInResult: false,
});

Convex storage

convexStorageAdapter persists each tile, preview, overlay, and manifest into Convex file storage and records a row per artifact in a user-owned mapAssets table. URLs are resolved at request time by the viewer via the getTileUrl hook — do not set baseUrl on the TilesSource, since Convex URLs are signed and time-limited.

The adapter stays decoupled from specific Convex function names (same philosophy as the S3-compatible adapter). You provide a storeArtifact callback that talks to your own Convex deployment.

Convex schema (convex/schema.ts):

import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  mapAssets: defineTable({
    mapId: v.string(),
    relativePath: v.string(),
    storageId: v.id("_storage"),
    kind: v.union(
      v.literal("tile"),
      v.literal("manifest"),
      v.literal("preview"),
      v.literal("overlay"),
    ),
    contentType: v.string(),
    size: v.number(),
  }).index("by_map_and_path", ["mapId", "relativePath"]),
});

Mutations and query (convex/maps.ts):

import { mutation, query } from "./_generated/server";
import { v } from "convex/values";

export const generateUploadUrl = mutation({
  args: {},
  handler: async (ctx) => await ctx.storage.generateUploadUrl(),
});

export const recordMapArtifact = mutation({
  args: {
    mapId: v.string(),
    relativePath: v.string(),
    storageId: v.id("_storage"),
    kind: v.union(
      v.literal("tile"),
      v.literal("manifest"),
      v.literal("preview"),
      v.literal("overlay"),
    ),
    contentType: v.string(),
    size: v.number(),
  },
  handler: async (ctx, args) => {
    // Insert-only to minimize OCC contention during concurrent ingest.
    // Add upsert logic (query by_map_and_path, delete old storageId, then insert)
    // if you need to re-ingest a map idempotently.
    await ctx.db.insert("mapAssets", args);
  },
});

export const getTileUrl = query({
  args: { mapId: v.string(), relativePath: v.string() },
  handler: async (ctx, { mapId, relativePath }) => {
    const row = await ctx.db
      .query("mapAssets")
      .withIndex("by_map_and_path", (q) =>
        q.eq("mapId", mapId).eq("relativePath", relativePath),
      )
      .unique();
    return row ? await ctx.storage.getUrl(row.storageId) : null;
  },
});

Ingest caller (Node — CLI, TanStack Start server function, or Next.js route handler):

import { ConvexHttpClient } from "convex/browser";
import { api } from "../convex/_generated/api";
import { ingestPdf, convexStorageAdapter } from "pdf-mapview/ingest";

const convex = new ConvexHttpClient(process.env.CONVEX_URL!);

const storage = convexStorageAdapter({
  mapId: "site-plan-001",
  async storeArtifact({ relativePath, bytes, contentType, kind, size, mapId }) {
    const uploadUrl = await convex.mutation(api.maps.generateUploadUrl, {});
    const res = await fetch(uploadUrl, {
      method: "POST",
      headers: { "Content-Type": contentType },
      body: bytes,
    });
    const { storageId } = (await res.json()) as { storageId: string };

    await convex.mutation(api.maps.recordMapArtifact, {
      mapId,
      relativePath,
      storageId,
      contentType,
      kind,
      size,
    });

    return { storageId };
  },
});

await ingestPdf({
  input: "./plans/site-plan.pdf",
  id: "site-plan-001",      // keep in sync with convexStorageAdapter.mapId
  storage,
  writeConcurrency: 4,       // lower than the S3 default to respect Convex mutation throughput
  retainFilesInResult: false,
});

Viewer wiring:

import { TileMapViewer } from "pdf-mapview/client";
import { useConvex } from "convex/react";
import { api } from "../convex/_generated/api";

function Floorplan({ manifest }: { manifest: any }) {
  const convex = useConvex();
  return (
    <TileMapViewer
      source={{
        type: "tiles",
        manifest,
        // Deliberately no baseUrl — Convex URLs are per-tile signed.
        async getTileUrl({ z, x, y, manifest, signal }) {
          const ext = manifest.tiles.format === "jpeg" ? "jpg" : manifest.tiles.format;
          const url = await convex.query(api.maps.getTileUrl, {
            mapId: manifest.id,
            relativePath: `tiles/${z}/${x}/${y}.${ext}`,
          });
          if (!url) throw new Error(`tile not found: ${z}/${x}/${y}`);
          return url;
        },
      }}
    />
  );
}

Notes and caveats:

  • convexStorageAdapter.mapId and the id you pass to ingestPdf / ingestImage identify the same map — keep them in sync.
  • TilesSource.baseUrl should not be set — signed Convex URLs are only valid via getTileUrl.
  • Ingest runs in Node because sharp and @napi-rs/canvas are native modules — it cannot execute inside a Convex action runtime.
  • The recordMapArtifact example above is insert-only; add upsert logic (query the existing row, delete its old storageId via ctx.storage.delete, then insert) if you need to re-ingest a map idempotently.
  • If you see OCC mutation conflicts under load, lower writeConcurrency (e.g. 4). All recordMapArtifact calls touch the same table partitioned by mapId, so mutation throughput per map is bounded by Convex OCC retries.

Performance tuning

Ingest runs in streaming mode — tiles and previews live on disk until storage adapters copy them into place. A few knobs let you trade memory, CPU, and latency for your target hardware.

retainFilesInResult

By default, IngestResult.files is populated with every tile, preview, and overlay as an in-memory OutputArtifact. This is convenient when the caller wants to inspect or re-upload output, but it forces the pipeline to read every tile back from disk.

For large maps going directly to a storage adapter (local disk, S3, CDN), set retainFilesInResult: false. The adapter already has the bytes; the pipeline then skips the final read-back and result.files comes back as an empty array. Expect a large memory and wall-clock drop on >1,000-tile outputs.

await ingestPdf({
  input: "./plans/site-plan.pdf",
  id: "site-plan-001",
  storage: s3Storage,
  retainFilesInResult: false,
});

writeConcurrency

Controls how many tiles/previews are written to the storage adapter in parallel. Default: min(8, os.availableParallelism()).

  • Raise it (e.g. 1632) for network-bound adapters like S3 — uploads are I/O-bound and benefit from parallelism beyond CPU count.
  • Leave it alone for local disk on modern SSDs — the default saturates most disks.
  • Lower it if you see EMFILE/ETIMEDOUT against a flaky backend.

rasterDpi vs maxDimension (PDF only)

Both bound the pixel budget for PDF rasterization but behave differently:

  • rasterDpi: fixed DPI regardless of page size. Predictable quality (300 is good for text-heavy plans). Larger PDF pages produce larger rasters.
  • maxDimension (default 12288): rasterizes at whatever DPI fits within maxDimension × maxDimension pixels. Produces a consistent upper bound on raster cost regardless of page size, but DPI varies per page.

Pick rasterDpi when print-style fidelity matters; keep maxDimension when you want predictable ingest cost.

tileFormat and tileQuality

| Format | Use when | Notes | | -------- | ------------------------------------------------------- | ---------------------------------- | | webp | default; best size/quality ratio | universal browser support in 2024+ | | jpeg | photographic content (satellite, orthophotos, scans) | no transparency | | png | sharp UI/diagrams where lossless matters | much larger files |

tileQuality (default 92) applies to webp and jpeg. Drop to 8085 for another ~20–30% size reduction with imperceptible visual change on most content.

Ingest options reference

| Option | Type | Default | Applies to | | --------------------- | ------------------------- | ------------------------- | ------------ | | id | string | slugified filename | both | | title | string | — | both | | tileSize | 256 | 512 | 256 | both | | tileFormat | "webp"|"jpeg"|"png" | "webp" | both | | tileQuality | number | 92 | both | | maxDimension | number | 12288 | both | | rasterDpi | number | — | PDF | | background | CSS color | "#ffffff" | both | | overlays | RegionCollection \| URL | — | both | | baseUrl | string | — | both | | storage | StorageAdapter | memoryStorageAdapter() | both | | writeConcurrency | number | min(8, cpu count) | both | | retainFilesInResult | boolean | true | both | | onProgress | IngestProgressCallback | — | both | | page | number | 1 | PDF |

Progress reporting

Pass onProgress to observe each stage of the ingest pipeline. The callback is invoked at every milestone with a single discriminated-union event, and each call is awaited — the next event is never emitted until the previous callback settles. That makes it safe to sequence async side effects (e.g. database writes) off each event without worrying about ordering or overlap.

import type { IngestProgressEvent } from "pdf-mapview";

await ingestPdf({
  input: "./plans/site-plan.pdf",
  id: "site-plan-001",
  storage: s3Storage,
  onProgress: async (event: IngestProgressEvent) => {
    await recordStage(event); // runs to completion before the next event fires
  },
});

Event order

  • Image: tile-build:level-complete (×N) → upload:artifact-complete (×M) → finalize:complete
  • PDF: rasterize:startrasterize:completetile-build:level-complete (×N) → upload:artifact-complete (×M) → finalize:complete

totalLevels, totalTiles, and totalArtifacts are populated on the first event of each stage, and their completed* counterparts never decrease within a stage. The terminal tile-build event has completedLevels === totalLevels and completedTiles === totalTiles; the terminal upload event has completedArtifacts === totalArtifacts.

A thrown or rejected callback propagates out of the ingest* call and aborts the ingest — no upload or finalize events are delivered after the throw.

Note. Tile-build events are emitted after the underlying sharp.tile() call completes the full pyramid, not while sharp is still working. Sharp doesn't expose per-level callbacks, so the N level-complete events fire in rapid succession at the end of the build. The strict-ordering, known-totals, and monotonic-counter guarantees all still hold, and the weighted-progress formula below still produces a smooth progress bar.

Event shape

See IngestProgressEvent in the package types for the full union. In summary:

| Stage / phase | Carries | | -------------------------------- | ----------------------------------------------------------------------- | | rasterize / start | effectiveDpi, requestedDpi?, maxDimension? | | rasterize / complete | width, height, effectiveDpi | | tile-build / level-complete | completedLevels, totalLevels, completedTiles, totalTiles, zoom, levelTileCount | | upload / artifact-complete | completedArtifacts, totalArtifacts, path, kind | | finalize / complete | — (terminal marker) |

Weighted progress formula

Most UIs want a single 0–1 number rather than raw event fields. A simple weighted scheme that honors the known totals on each event:

// Stage weights that sum to 1. Tune per app.
const WEIGHTS = { rasterize: 0.15, build: 0.5, upload: 0.3, finalize: 0.05 };

function progressFromEvent(e: IngestProgressEvent, cumulative: number): number {
  switch (e.stage) {
    case "rasterize":
      return e.phase === "complete" ? WEIGHTS.rasterize : 0;
    case "tile-build":
      return WEIGHTS.rasterize + WEIGHTS.build * (e.completedTiles / e.totalTiles);
    case "upload":
      return WEIGHTS.rasterize + WEIGHTS.build
        + WEIGHTS.upload * (e.completedArtifacts / e.totalArtifacts);
    case "finalize":
      return 1;
  }
}

End-to-end example

import { ingestPdf, type IngestProgressEvent } from "pdf-mapview/server";

let bar = 0;
await ingestPdf({
  input: "./plans/site-plan.pdf",
  id: "site-plan-001",
  storage: s3Storage,
  onProgress: async (event: IngestProgressEvent) => {
    bar = progressFromEvent(event, bar);
    // Awaited DB/UI write — the next event won't fire until this resolves.
    await db.updateIngestProgress({ id: "site-plan-001", progress: bar, event });
  },
});

CLI

pdf-mapview ingest ./plans/site-plan.pdf \
  --page 1 \
  --id site-plan-001 \
  --out-dir ./public/maps/site-plan-001

CLI flags

| Flag | Description | Default | | -------------------------------- | ------------------------------------------------------------------------------------ | ---------------------- | | --page <n> | PDF page to ingest | 1 | | --id <id> | Manifest id | slugified filename | | --title <title> | Manifest title | — | | --out-dir <outDir> | Write output to a local directory | — | | --type <pdf\|image> | Force source type | inferred from filename | | --base-url <baseUrl> | Base URL for manifest asset references | — | | --adapter <modulePath> | Custom storage adapter factory (JS/TS module) | — | | --adapter-export <name> | Named export on the adapter module | default | | --tile-size <256\|512> | Tile size | 256 | | --format <webp\|jpeg\|png> | Tile format | webp | | --quality <n> | Tile quality (0–100, webp/jpeg) | 92 | | --raster-dpi <n> | Rasterize PDF pages at a fixed DPI | — | | --max-dimension <n> | Max raster dimension | 12288 | | --write-concurrency <n> | Parallel tile/asset writes | min(8, cpu count) | | --no-retain-files | Skip populating result.files — lower memory for large maps writing to disk/S3 | files retained |

Serving tiles

Generated tiles are static files under tiles/{z}/{x}/{y}.{ext}. Serve them as aggressively cached immutable assets:

  • Cache-Control: public, max-age=31536000, immutable on tiles and preview.webp
  • Cache-Control: public, max-age=60 on manifest.json and regions.json
  • Access-Control-Allow-Origin set for the domain(s) that will render the viewer, if tiles are on a different host

The S3-compatible adapter already sets cache headers; match them on any custom backend.

PDF worker hosting

The PDF fallback (and PDF ingest on the server) uses pdfjs-dist, which requires a worker bundle. pdf-mapview/web-worker exports pdfWorkerUrl, a new URL(...) reference to the copy in dist/pdf.worker.min.mjs.

  • Vite, TanStack Startnew URL(..., import.meta.url) is understood natively; pdfWorkerUrl resolves to a hashed asset in your build output.

  • Next.js App Router / Turbopack — same; no extra config needed.

  • Webpack 5 — same; asset/resource handles the URL import automatically.

  • Plain static hosting — copy node_modules/pdf-mapview/dist/pdf.worker.min.mjs into your public directory and pass the URL directly:

    <TileMapViewer source={{ type: "pdf", file: "/plan.pdf", workerSrc: "/pdf.worker.min.mjs" }} />

Manifest

Generated manifests are versioned and viewer-complete. The viewer can load tiles from static hosting, object storage, or signed URL providers.

{
  "version": 1,
  "kind": "pdf-map",
  "id": "site-plan-001",
  "source": {
    "type": "pdf",
    "page": 1,
    "width": 12000,
    "height": 9000,
    "rasterization": {
      "mode": "dpi",
      "requestedDpi": 300,
      "effectiveDpi": 300
    }
  },
  "tiles": {
    "tileSize": 256,
    "format": "webp",
    "minZoom": 0,
    "maxZoom": 6,
    "pathTemplate": "tiles/{z}/{x}/{y}.webp"
  }
}

TanStack Start

Client code should import only from pdf-mapview/client, and ingest code should live in server functions or build steps via pdf-mapview/server.

See the TanStack Start example notes: