Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
[submodule "fixtures/geotiff-test-data"]
path = fixtures/geotiff-test-data
url = https://github.com/developmentseed/geotiff-test-data
[submodule "packages/morecantile/spec"]
path = packages/morecantile/spec
url = https://github.com/opengeospatial/2D-Tile-Matrix-Set
3 changes: 2 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"!!**/node_modules",
"!!**/coverage",
"!!**/*.log",
"!!**/fixtures"
"!!**/fixtures",
"!!packages/morecantile/spec"
]
},
"formatter": {
Expand Down
10 changes: 10 additions & 0 deletions packages/morecantile/DEVELOP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## Type generation

We use `json-schema-to-typescript` to generate TypeScript types from the original TMS JSON Schema source located. The original source is in `/spec/schemas/tms/2.0/json/`, and the generated files are in `src/types/spec/`.

Use
```
pnpm generate-types
```

to regenerate the TypeScript types.
53 changes: 53 additions & 0 deletions packages/morecantile/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"name": "@developmentseed/morecantile",
"version": "0.1.0",
"description": "TypeScript port of Python morecantile — TileMatrixSet utilities.",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"sideEffects": false,
"files": [
"dist"
],
"scripts": {
"build": "tsc --build tsconfig.build.json",
"generate-types": "tsx scripts/generate-types.ts",
"prepublishOnly": "npm run build",
"test:watch": "vitest",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"keywords": [
"tms",
"tile-matrix-set",
"morecantile",
"xyz",
"tiles",
"geospatial"
],
"author": "Development Seed",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/developmentseed/deck.gl-raster.git"
},
"devDependencies": {
"@types/node": "^25.1.0",
"jsdom": "^27.4.0",
"json-schema-to-typescript": "^15.0.4",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
},
"dependencies": {},
"peerDependencies": {},
"volta": {
"extends": "../../package.json"
}
}
145 changes: 145 additions & 0 deletions packages/morecantile/scripts/generate-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* Generates TypeScript types from OGC TMS 2.0 JSON Schema files.
*
* Each schema is compiled independently with `declareExternallyReferenced: false`
* so that $ref'd types appear as bare names. We then prepend the correct import
* statements based on the known dependency graph.
*
* Only the schemas reachable from the four public exports (BoundingBox, CRS,
* TileMatrix, TileMatrixSet) are generated. The projJSON.json schema (~1000
* lines of CRS subtypes) is replaced with an opaque `ProjJSON` type alias.
*/

import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { compileFromFile } from "json-schema-to-typescript";

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, "..");
const SCHEMA_DIR = resolve(ROOT, "spec/schemas/tms/2.0/json");
const OUT_DIR = resolve(ROOT, "src/types/spec");

const BANNER = `/* This file was automatically generated from OGC TMS 2.0 JSON Schema. */
/* DO NOT MODIFY IT BY HAND. Instead, modify the source JSON Schema file */
/* and run \`pnpm run generate-types\` to regenerate. */`;

/**
* Map from schema filename (without .json) to the type names it exports.
* Only schemas in the transitive closure of the four public types are listed.
*/
const SCHEMA_EXPORTS: Record<string, string[]> = {
"2DPoint": ["DPoint"],
"2DBoundingBox": ["DBoundingBox"],
crs: ["CRS"],
variableMatrixWidth: ["VariableMatrixWidth"],
tileMatrix: ["TileMatrix"],
tileMatrixSet: ["TileMatrixSetDefinition"],
};

/**
* Map from schema filename to the schema files it $ref's.
* projJSON is excluded — handled separately as an opaque type.
*/
const SCHEMA_DEPS: Record<string, string[]> = {
"2DPoint": [],
"2DBoundingBox": ["2DPoint", "crs"],
crs: [],
variableMatrixWidth: [],
tileMatrix: ["2DPoint", "variableMatrixWidth"],
tileMatrixSet: ["crs", "2DBoundingBox", "tileMatrix"],
};

/** Build an import block, only including types that appear in the generated code. */
function buildImports(schemaName: string, generatedCode: string): string {
const deps = SCHEMA_DEPS[schemaName];
if (!deps || deps.length === 0) return "";

const lines: string[] = [];
for (const dep of deps) {
const types = SCHEMA_EXPORTS[dep];
if (!types) throw new Error(`Unknown schema dependency: ${dep}`);
// Only import types that are actually referenced in the generated output
const usedTypes = types.filter((t) =>
new RegExp(`\\b${t}\\b`).test(generatedCode),
);
if (usedTypes.length > 0) {
lines.push(`import type { ${usedTypes.join(", ")} } from "./${dep}.js";`);
}
}

return lines.length > 0 ? `${lines.join("\n")}\n\n` : "";
}

/** Compile a single schema file, returning the generated TypeScript. */
async function compileSchema(schemaName: string): Promise<string> {
const ts = await compileFromFile(`${SCHEMA_DIR}/${schemaName}.json`, {
cwd: SCHEMA_DIR,
declareExternallyReferenced: false,
format: false,
bannerComment: "",
$refOptions: {
resolve: {
projjson: {
order: 1,
canRead: /projJSON/,
read: () => JSON.stringify({ type: "object", title: "ProjJSON" }),
},
},
},
});

// The OGC schemas attach descriptions to $ref properties via
// allOf: [{ description }, { $ref }]. json-schema-to-typescript renders
// the description-only object as { [k: string]: unknown } and intersects
// it with the $ref'd type. That intersection makes the type incompatible
// with plain JSON imports. Strip it — only the $ref'd type name matters.
return ts.replace(/\(\{\s*\[k: string\]: unknown\s*\} & (\w+)\)/g, "$1");
}

async function main() {
mkdirSync(OUT_DIR, { recursive: true });

// Generate the opaque ProjJSON type file
const projJsonContent = [
BANNER,
"",
"/** Opaque type for PROJ JSON coordinate reference system definitions. */",
"export type ProjJSON = Record<string, unknown>;",
"",
].join("\n");
writeFileSync(resolve(OUT_DIR, "projJSON.ts"), projJsonContent);

const schemaNames = Object.keys(SCHEMA_EXPORTS);

for (const name of schemaNames) {
const rawTs = await compileSchema(name);
const imports = buildImports(name, rawTs);

// crs.ts needs the ProjJSON import since we stubbed out the schema
const projImport =
name === "crs"
? 'import type { ProjJSON } from "./projJSON.js";\n\n'
: "";

const content = [BANNER, "", projImport + imports + rawTs].join("\n");
const outPath = resolve(OUT_DIR, `${name}.ts`);
writeFileSync(outPath, content);
console.log(` ${name}.ts`);
}

// Generate barrel index.ts
const reexports = ["projJSON", ...schemaNames]
.map((name) => `export * from "./${name}.js";`)
.join("\n");
const indexContent = [BANNER, "", reexports, ""].join("\n");
writeFileSync(resolve(OUT_DIR, "index.ts"), indexContent);
console.log(" index.ts");

console.log(`\nGenerated ${schemaNames.length + 2} files in src/types/spec/`);
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
13 changes: 13 additions & 0 deletions packages/morecantile/scripts/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["node"],
"rootDir": ".",
"outDir": null,
"declarationMap": false,
"sourceMap": false,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["."]
}
1 change: 1 addition & 0 deletions packages/morecantile/spec
Submodule spec added at 324cf6
9 changes: 9 additions & 0 deletions packages/morecantile/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type { Affine } from "./transform.js";
export { matrixTransform, tileTransform } from "./transform.js";
export type {
BoundingBox,
CRS,
TileMatrix,
TileMatrixSet,
} from "./types/index.js";
export { metersPerUnit } from "./utils.js";
104 changes: 104 additions & 0 deletions packages/morecantile/src/transform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { TileMatrix } from "./types/index.js";

/**
* A 2-D affine transform as a six-element tuple in row-major order:
*
* x_crs = a * col_px + b * row_px + c
* y_crs = d * col_px + e * row_px + f
*
* For the north-up, axis-aligned grids produced by OGC TileMatrices,
* b and d are always zero, but the type does not enforce that.
*
* Note: the meaning of the two output axes depends on the
* `orderedAxes` field of the parent TileMatrixSet, which these
* helpers do not consult. Axis 0 of pointOfOrigin is axis 0 of the
* transform output, whatever that axis happens to be.
*/
export type Affine = [
a: number,
b: number,
c: number,
d: number,
e: number,
f: number,
];

/**
* Construct a single affine transform that maps pixel coordinates
* within *any* tile of the matrix to CRS coordinates.
*
* Returns `null` when the matrix declares `variableMatrixWidths`,
* because coalesced rows have a different X pixel size than the rest
* and cannot be described by one transform. Use {@link tileTransform}
* in that case.
*
* Pixel (0, 0) is the top-left corner of tile (0, 0). The column
* pixel index runs across the full matrix:
* globalPixelCol = col * tileWidth + pixelWithinTile_x
* globalPixelRow = row * tileHeight + pixelWithinTile_y
*/
export function matrixTransform(matrix: TileMatrix): Affine | null {
if (matrix.variableMatrixWidths && matrix.variableMatrixWidths.length > 0) {
return null;
}

const [originX, originY] = matrix.pointOfOrigin;
const ySign = matrix.cornerOfOrigin === "bottomLeft" ? 1 : -1;

return [
matrix.cellSize, // a: x per pixel-col
0, // b
originX, // c: x origin
0, // d
ySign * matrix.cellSize, // e: y per pixel-row
originY, // f: y origin
];
}

/**
* Construct an affine transform for a single tile identified by its
* column and row indices within the matrix. Pixel (0, 0) is the
* top-left corner of *this* tile.
*
* This is always possible: even when `variableMatrixWidths` is
* present, each individual tile is a plain rectangular pixel grid
* with a well-defined, axis-aligned footprint. Coalescence only
* stretches the tile in X; Y is unaffected.
*/
export function tileTransform(
matrix: TileMatrix,
tile: { col: number; row: number },
): Affine {
const coalesce = coalesceForRow(matrix, tile.row);

const [originX, originY] = matrix.pointOfOrigin;
const ySign = matrix.cornerOfOrigin === "bottomLeft" ? 1 : -1;

const tileSpanX = coalesce * matrix.cellSize * matrix.tileWidth;
const tileSpanY = matrix.cellSize * matrix.tileHeight;

return [
coalesce * matrix.cellSize, // a: x per pixel-col (stretched by coalesce)
0, // b
originX + tile.col * tileSpanX, // c: x origin of this tile
0, // d
ySign * matrix.cellSize, // e: y per pixel-row (unchanged)
originY + ySign * tile.row * tileSpanY, // f: y origin of this tile
];
}

/**
* Return the coalesce factor for a given row, or 1 if the row is not
* coalesced (or the matrix has no variableMatrixWidths at all).
*/
function coalesceForRow(matrix: TileMatrix, row: number): number {
if (!matrix.variableMatrixWidths) return 1;

for (const vmw of matrix.variableMatrixWidths) {
if (row >= vmw.minTileRow && row <= vmw.maxTileRow) {
return vmw.coalesce;
}
}

return 1;
}
4 changes: 4 additions & 0 deletions packages/morecantile/src/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type { DBoundingBox as BoundingBox } from "./spec/2DBoundingBox";
export type { CRS } from "./spec/crs";
export type { TileMatrix } from "./spec/tileMatrix";
export type { TileMatrixSetDefinition as TileMatrixSet } from "./spec/tileMatrixSet";
21 changes: 21 additions & 0 deletions packages/morecantile/src/types/spec/2DBoundingBox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/* This file was automatically generated from OGC TMS 2.0 JSON Schema. */
/* DO NOT MODIFY IT BY HAND. Instead, modify the source JSON Schema file */
/* and run `pnpm run generate-types` to regenerate. */

import type { DPoint } from "./2DPoint.js";
import type { CRS } from "./crs.js";

/**
* Minimum bounding rectangle surrounding a 2D resource in the CRS indicated elsewhere
*/
export interface DBoundingBox {
lowerLeft: DPoint;
upperRight: DPoint;
crs?: CRS;
/**
* @minItems 2
* @maxItems 2
*/
orderedAxes?: [string, string];
[k: string]: unknown;
}
11 changes: 11 additions & 0 deletions packages/morecantile/src/types/spec/2DPoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/* This file was automatically generated from OGC TMS 2.0 JSON Schema. */
/* DO NOT MODIFY IT BY HAND. Instead, modify the source JSON Schema file */
/* and run `pnpm run generate-types` to regenerate. */

/**
* A 2D Point in the CRS indicated elsewhere
*
* @minItems 2
* @maxItems 2
*/
export type DPoint = [number, number];
Loading