Skip to content

Commit 20b3ace

Browse files
authored
feat: Create morecantile subpackage (#238)
This moves in https://github.com/developmentseed/morecantile-ts from an external repo into this monorepo. I think it'll be easier to develop when it's in this monorepo.
1 parent 559dc03 commit 20b3ace

25 files changed

+978
-25
lines changed

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
[submodule "fixtures/geotiff-test-data"]
22
path = fixtures/geotiff-test-data
33
url = https://github.com/developmentseed/geotiff-test-data
4+
[submodule "packages/morecantile/spec"]
5+
path = packages/morecantile/spec
6+
url = https://github.com/opengeospatial/2D-Tile-Matrix-Set

biome.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
"!!**/node_modules",
1616
"!!**/coverage",
1717
"!!**/*.log",
18-
"!!**/fixtures"
18+
"!!**/fixtures",
19+
"!!packages/morecantile/spec"
1920
]
2021
},
2122
"formatter": {

packages/morecantile/DEVELOP.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
## Type generation
2+
3+
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/`.
4+
5+
Use
6+
```
7+
pnpm generate-types
8+
```
9+
10+
to regenerate the TypeScript types.

packages/morecantile/package.json

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
{
2+
"name": "@developmentseed/morecantile",
3+
"version": "0.1.0",
4+
"description": "TypeScript port of Python morecantile — TileMatrixSet utilities.",
5+
"type": "module",
6+
"main": "./dist/index.js",
7+
"types": "./dist/index.d.ts",
8+
"exports": {
9+
".": {
10+
"types": "./dist/index.d.ts",
11+
"default": "./dist/index.js"
12+
}
13+
},
14+
"sideEffects": false,
15+
"files": [
16+
"dist"
17+
],
18+
"scripts": {
19+
"build": "tsc --build tsconfig.build.json",
20+
"generate-types": "tsx scripts/generate-types.ts",
21+
"prepublishOnly": "npm run build",
22+
"test:watch": "vitest",
23+
"test": "vitest run",
24+
"typecheck": "tsc --noEmit"
25+
},
26+
"keywords": [
27+
"tms",
28+
"tile-matrix-set",
29+
"morecantile",
30+
"xyz",
31+
"tiles",
32+
"geospatial"
33+
],
34+
"author": "Development Seed",
35+
"license": "MIT",
36+
"repository": {
37+
"type": "git",
38+
"url": "git+https://github.com/developmentseed/deck.gl-raster.git"
39+
},
40+
"devDependencies": {
41+
"@types/node": "^25.1.0",
42+
"jsdom": "^27.4.0",
43+
"json-schema-to-typescript": "^15.0.4",
44+
"tsx": "^4.21.0",
45+
"typescript": "^5.9.3",
46+
"vitest": "^4.0.18"
47+
},
48+
"dependencies": {},
49+
"peerDependencies": {},
50+
"volta": {
51+
"extends": "../../package.json"
52+
}
53+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* Generates TypeScript types from OGC TMS 2.0 JSON Schema files.
3+
*
4+
* Each schema is compiled independently with `declareExternallyReferenced: false`
5+
* so that $ref'd types appear as bare names. We then prepend the correct import
6+
* statements based on the known dependency graph.
7+
*
8+
* Only the schemas reachable from the four public exports (BoundingBox, CRS,
9+
* TileMatrix, TileMatrixSet) are generated. The projJSON.json schema (~1000
10+
* lines of CRS subtypes) is replaced with an opaque `ProjJSON` type alias.
11+
*/
12+
13+
import { mkdirSync, writeFileSync } from "node:fs";
14+
import { dirname, resolve } from "node:path";
15+
import { fileURLToPath } from "node:url";
16+
import { compileFromFile } from "json-schema-to-typescript";
17+
18+
const __dirname = dirname(fileURLToPath(import.meta.url));
19+
const ROOT = resolve(__dirname, "..");
20+
const SCHEMA_DIR = resolve(ROOT, "spec/schemas/tms/2.0/json");
21+
const OUT_DIR = resolve(ROOT, "src/types/spec");
22+
23+
const BANNER = `/* This file was automatically generated from OGC TMS 2.0 JSON Schema. */
24+
/* DO NOT MODIFY IT BY HAND. Instead, modify the source JSON Schema file */
25+
/* and run \`pnpm run generate-types\` to regenerate. */`;
26+
27+
/**
28+
* Map from schema filename (without .json) to the type names it exports.
29+
* Only schemas in the transitive closure of the four public types are listed.
30+
*/
31+
const SCHEMA_EXPORTS: Record<string, string[]> = {
32+
"2DPoint": ["DPoint"],
33+
"2DBoundingBox": ["DBoundingBox"],
34+
crs: ["CRS"],
35+
variableMatrixWidth: ["VariableMatrixWidth"],
36+
tileMatrix: ["TileMatrix"],
37+
tileMatrixSet: ["TileMatrixSetDefinition"],
38+
};
39+
40+
/**
41+
* Map from schema filename to the schema files it $ref's.
42+
* projJSON is excluded — handled separately as an opaque type.
43+
*/
44+
const SCHEMA_DEPS: Record<string, string[]> = {
45+
"2DPoint": [],
46+
"2DBoundingBox": ["2DPoint", "crs"],
47+
crs: [],
48+
variableMatrixWidth: [],
49+
tileMatrix: ["2DPoint", "variableMatrixWidth"],
50+
tileMatrixSet: ["crs", "2DBoundingBox", "tileMatrix"],
51+
};
52+
53+
/** Build an import block, only including types that appear in the generated code. */
54+
function buildImports(schemaName: string, generatedCode: string): string {
55+
const deps = SCHEMA_DEPS[schemaName];
56+
if (!deps || deps.length === 0) return "";
57+
58+
const lines: string[] = [];
59+
for (const dep of deps) {
60+
const types = SCHEMA_EXPORTS[dep];
61+
if (!types) throw new Error(`Unknown schema dependency: ${dep}`);
62+
// Only import types that are actually referenced in the generated output
63+
const usedTypes = types.filter((t) =>
64+
new RegExp(`\\b${t}\\b`).test(generatedCode),
65+
);
66+
if (usedTypes.length > 0) {
67+
lines.push(`import type { ${usedTypes.join(", ")} } from "./${dep}.js";`);
68+
}
69+
}
70+
71+
return lines.length > 0 ? `${lines.join("\n")}\n\n` : "";
72+
}
73+
74+
/** Compile a single schema file, returning the generated TypeScript. */
75+
async function compileSchema(schemaName: string): Promise<string> {
76+
const ts = await compileFromFile(`${SCHEMA_DIR}/${schemaName}.json`, {
77+
cwd: SCHEMA_DIR,
78+
declareExternallyReferenced: false,
79+
format: false,
80+
bannerComment: "",
81+
$refOptions: {
82+
resolve: {
83+
projjson: {
84+
order: 1,
85+
canRead: /projJSON/,
86+
read: () => JSON.stringify({ type: "object", title: "ProjJSON" }),
87+
},
88+
},
89+
},
90+
});
91+
92+
// The OGC schemas attach descriptions to $ref properties via
93+
// allOf: [{ description }, { $ref }]. json-schema-to-typescript renders
94+
// the description-only object as { [k: string]: unknown } and intersects
95+
// it with the $ref'd type. That intersection makes the type incompatible
96+
// with plain JSON imports. Strip it — only the $ref'd type name matters.
97+
return ts.replace(/\(\{\s*\[k: string\]: unknown\s*\} & (\w+)\)/g, "$1");
98+
}
99+
100+
async function main() {
101+
mkdirSync(OUT_DIR, { recursive: true });
102+
103+
// Generate the opaque ProjJSON type file
104+
const projJsonContent = [
105+
BANNER,
106+
"",
107+
"/** Opaque type for PROJ JSON coordinate reference system definitions. */",
108+
"export type ProjJSON = Record<string, unknown>;",
109+
"",
110+
].join("\n");
111+
writeFileSync(resolve(OUT_DIR, "projJSON.ts"), projJsonContent);
112+
113+
const schemaNames = Object.keys(SCHEMA_EXPORTS);
114+
115+
for (const name of schemaNames) {
116+
const rawTs = await compileSchema(name);
117+
const imports = buildImports(name, rawTs);
118+
119+
// crs.ts needs the ProjJSON import since we stubbed out the schema
120+
const projImport =
121+
name === "crs"
122+
? 'import type { ProjJSON } from "./projJSON.js";\n\n'
123+
: "";
124+
125+
const content = [BANNER, "", projImport + imports + rawTs].join("\n");
126+
const outPath = resolve(OUT_DIR, `${name}.ts`);
127+
writeFileSync(outPath, content);
128+
console.log(` ${name}.ts`);
129+
}
130+
131+
// Generate barrel index.ts
132+
const reexports = ["projJSON", ...schemaNames]
133+
.map((name) => `export * from "./${name}.js";`)
134+
.join("\n");
135+
const indexContent = [BANNER, "", reexports, ""].join("\n");
136+
writeFileSync(resolve(OUT_DIR, "index.ts"), indexContent);
137+
console.log(" index.ts");
138+
139+
console.log(`\nGenerated ${schemaNames.length + 2} files in src/types/spec/`);
140+
}
141+
142+
main().catch((err) => {
143+
console.error(err);
144+
process.exit(1);
145+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"extends": "../tsconfig.json",
3+
"compilerOptions": {
4+
"types": ["node"],
5+
"rootDir": ".",
6+
"outDir": null,
7+
"declarationMap": false,
8+
"sourceMap": false,
9+
"noUnusedLocals": false,
10+
"noUnusedParameters": false
11+
},
12+
"include": ["."]
13+
}

packages/morecantile/spec

Submodule spec added at 324cf6b

packages/morecantile/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export type { Affine } from "./transform.js";
2+
export { matrixTransform, tileTransform } from "./transform.js";
3+
export type {
4+
BoundingBox,
5+
CRS,
6+
TileMatrix,
7+
TileMatrixSet,
8+
} from "./types/index.js";
9+
export { metersPerUnit } from "./utils.js";
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type { TileMatrix } from "./types/index.js";
2+
3+
/**
4+
* A 2-D affine transform as a six-element tuple in row-major order:
5+
*
6+
* x_crs = a * col_px + b * row_px + c
7+
* y_crs = d * col_px + e * row_px + f
8+
*
9+
* For the north-up, axis-aligned grids produced by OGC TileMatrices,
10+
* b and d are always zero, but the type does not enforce that.
11+
*
12+
* Note: the meaning of the two output axes depends on the
13+
* `orderedAxes` field of the parent TileMatrixSet, which these
14+
* helpers do not consult. Axis 0 of pointOfOrigin is axis 0 of the
15+
* transform output, whatever that axis happens to be.
16+
*/
17+
export type Affine = [
18+
a: number,
19+
b: number,
20+
c: number,
21+
d: number,
22+
e: number,
23+
f: number,
24+
];
25+
26+
/**
27+
* Construct a single affine transform that maps pixel coordinates
28+
* within *any* tile of the matrix to CRS coordinates.
29+
*
30+
* Returns `null` when the matrix declares `variableMatrixWidths`,
31+
* because coalesced rows have a different X pixel size than the rest
32+
* and cannot be described by one transform. Use {@link tileTransform}
33+
* in that case.
34+
*
35+
* Pixel (0, 0) is the top-left corner of tile (0, 0). The column
36+
* pixel index runs across the full matrix:
37+
* globalPixelCol = col * tileWidth + pixelWithinTile_x
38+
* globalPixelRow = row * tileHeight + pixelWithinTile_y
39+
*/
40+
export function matrixTransform(matrix: TileMatrix): Affine | null {
41+
if (matrix.variableMatrixWidths && matrix.variableMatrixWidths.length > 0) {
42+
return null;
43+
}
44+
45+
const [originX, originY] = matrix.pointOfOrigin;
46+
const ySign = matrix.cornerOfOrigin === "bottomLeft" ? 1 : -1;
47+
48+
return [
49+
matrix.cellSize, // a: x per pixel-col
50+
0, // b
51+
originX, // c: x origin
52+
0, // d
53+
ySign * matrix.cellSize, // e: y per pixel-row
54+
originY, // f: y origin
55+
];
56+
}
57+
58+
/**
59+
* Construct an affine transform for a single tile identified by its
60+
* column and row indices within the matrix. Pixel (0, 0) is the
61+
* top-left corner of *this* tile.
62+
*
63+
* This is always possible: even when `variableMatrixWidths` is
64+
* present, each individual tile is a plain rectangular pixel grid
65+
* with a well-defined, axis-aligned footprint. Coalescence only
66+
* stretches the tile in X; Y is unaffected.
67+
*/
68+
export function tileTransform(
69+
matrix: TileMatrix,
70+
tile: { col: number; row: number },
71+
): Affine {
72+
const coalesce = coalesceForRow(matrix, tile.row);
73+
74+
const [originX, originY] = matrix.pointOfOrigin;
75+
const ySign = matrix.cornerOfOrigin === "bottomLeft" ? 1 : -1;
76+
77+
const tileSpanX = coalesce * matrix.cellSize * matrix.tileWidth;
78+
const tileSpanY = matrix.cellSize * matrix.tileHeight;
79+
80+
return [
81+
coalesce * matrix.cellSize, // a: x per pixel-col (stretched by coalesce)
82+
0, // b
83+
originX + tile.col * tileSpanX, // c: x origin of this tile
84+
0, // d
85+
ySign * matrix.cellSize, // e: y per pixel-row (unchanged)
86+
originY + ySign * tile.row * tileSpanY, // f: y origin of this tile
87+
];
88+
}
89+
90+
/**
91+
* Return the coalesce factor for a given row, or 1 if the row is not
92+
* coalesced (or the matrix has no variableMatrixWidths at all).
93+
*/
94+
function coalesceForRow(matrix: TileMatrix, row: number): number {
95+
if (!matrix.variableMatrixWidths) return 1;
96+
97+
for (const vmw of matrix.variableMatrixWidths) {
98+
if (row >= vmw.minTileRow && row <= vmw.maxTileRow) {
99+
return vmw.coalesce;
100+
}
101+
}
102+
103+
return 1;
104+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export type { DBoundingBox as BoundingBox } from "./spec/2DBoundingBox";
2+
export type { CRS } from "./spec/crs";
3+
export type { TileMatrix } from "./spec/tileMatrix";
4+
export type { TileMatrixSetDefinition as TileMatrixSet } from "./spec/tileMatrixSet";

0 commit comments

Comments
 (0)