{"_id":"@ateliercartographie/proj-suggest","_rev":"2-0454b037f46388f36cd774fb86bd4f92","name":"@ateliercartographie/proj-suggest","dist-tags":{"latest":"0.1.0"},"versions":{"0.0.1":{"name":"@ateliercartographie/proj-suggest","version":"0.0.1","keywords":["projection","map","cartography","bbox","proj4","gis"],"author":{"url":"https://www.sciencespo.fr/cartographie/","name":"Thomas Ansart — Atelier de cartographie de Sciences Po"},"license":"ISC","_id":"@ateliercartographie/proj-suggest@0.0.1","maintainers":[{"name":"tombor","email":"thomas2ansart@gmail.com"}],"homepage":"https://github.com/AtelierCartographie/proj-suggest#readme","bugs":{"url":"https://github.com/AtelierCartographie/proj-suggest/issues"},"dist":{"shasum":"f61c332309f4662627d177e9448837b004032e61","tarball":"https://registry.npmjs.org/@ateliercartographie/proj-suggest/-/proj-suggest-0.0.1.tgz","fileCount":31,"integrity":"sha512-BkspNPBaEzUE48aU3q2EbL9EKLcBvp/wIQlR6vrTcyUuY3XIckzLhF5N6yiqIhc9mMPcdOqYnojJKJjsmALmFQ==","signatures":[{"sig":"MEQCIFpQFLzQuqMFLXzQgVm9peGku3M1krtmVGSjtLyaLDSZAiBlao2sR4MBYGvYb/EA1jrUwaqBR7XAdUtHzS5aSw7NAA==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":120110},"type":"module","types":"./dist/index.d.ts","engines":{"node":">=18"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"}},"gitHead":"fa76a97fa9c62296f635a074d331c2156aab2927","scripts":{"lint":"prettier --check .","test":"vitest","build":"tsc && publint","check":"tsc --noEmit","format":"prettier --write .","prepare":"tsc","prepublishOnly":"npm run build"},"_npmUser":{"name":"tombor","email":"thomas2ansart@gmail.com"},"repository":{"url":"git+https://github.com/AtelierCartographie/proj-suggest.git","type":"git"},"_npmVersion":"10.9.2","description":"Suggest map projections based on a bounding box, including national projections","directories":{},"sideEffects":false,"_nodeVersion":"22.22.2","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"vitest":"^4.1.0","publint":"^0.3.18","prettier":"^3.8.1","typescript":"^5.9.3"},"_npmOperationalInternal":{"tmp":"tmp/proj-suggest_0.0.1_1783503688525_0.8714693315195234","host":"s3://npm-registry-packages-npm-production"}},"0.1.0":{"_id":"@ateliercartographie/proj-suggest@0.1.0","bugs":{"url":"https://github.com/AtelierCartographie/proj-suggest/issues"},"dist":{"shasum":"f4f9b7042e4b28dfa97ea3947c9fd06df25dd1b9","tarball":"https://registry.npmjs.org/@ateliercartographie/proj-suggest/-/proj-suggest-0.1.0.tgz","fileCount":31,"integrity":"sha512-hP0v+RlkQ2boMimoQYOPVgDB3X8m8MvSK8ExIzvkMxy8FQjTh8R0dJicizTCHOgTycfkhNFO1FdsXBxEDclpgQ==","signatures":[{"sig":"MEUCIQDAglePC1uir9FE4Ala7NLzFq1dQUJOQRwbhNiaMtBp9QIgOtHWphDs8vz5HzP7Pp9LJi3V7vMYB0Wa9tY4nZNDP1c=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQC6JsmylgSvZkifYgxZyyGcKSXJg2hcUKSQFo4kKMybEAIhAImTTuEHdoazi4+gQ+7esN3rUrykDfnil3etuY87R+/Q"}],"unpackedSize":123574},"name":"@ateliercartographie/proj-suggest","type":"module","types":"./dist/index.d.ts","author":{"url":"https://www.sciencespo.fr/cartographie/","name":"Thomas Ansart — Atelier de cartographie de Sciences Po"},"engines":{"node":">=18"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"}},"license":"ISC","scripts":{"lint":"prettier --check .","test":"vitest","build":"tsc && publint","check":"tsc --noEmit","format":"prettier --write .","prepare":"tsc","prepublishOnly":"npm run build"},"version":"0.1.0","_npmUser":{"name":"tombor","email":"thomas2ansart@gmail.com"},"homepage":"https://github.com/AtelierCartographie/proj-suggest#readme","keywords":["projection","map","cartography","bbox","proj4","gis"],"repository":{"url":"git+https://github.com/AtelierCartographie/proj-suggest.git","type":"git"},"_npmVersion":"11.19.1","description":"Suggest map projections based on a bounding box, including national projections","directories":{},"maintainers":[{"name":"tombor","email":"thomas2ansart@gmail.com"}],"sideEffects":false,"_nodeVersion":"26.10.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"vitest":"^4.1.0","publint":"^0.3.18","prettier":"^3.8.1","typescript":"^5.9.3"},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/proj-suggest_0.1.0_1790858730003_0.3977824013044928"}}},"time":{"created":"2026-07-08T09:41:28.305Z","modified":"2026-10-01T12:45:30.261Z","0.0.1":"2026-07-08T09:41:28.656Z","0.1.0":"2026-10-01T12:45:30.122Z"},"bugs":{"url":"https://github.com/AtelierCartographie/proj-suggest/issues"},"author":{"url":"https://www.sciencespo.fr/cartographie/","name":"Thomas Ansart — Atelier de cartographie de Sciences Po"},"license":"ISC","homepage":"https://github.com/AtelierCartographie/proj-suggest#readme","keywords":["projection","map","cartography","bbox","proj4","gis"],"repository":{"url":"git+https://github.com/AtelierCartographie/proj-suggest.git","type":"git"},"description":"Suggest map projections based on a bounding box, including national projections","maintainers":[{"name":"tombor","email":"thomas2ansart@gmail.com"}],"readme":"# proj-suggest\n\n## English\n\nA dependency-free TypeScript library that suggests suitable map projections from a bounding box (bbox), including official national projections.\n\nThe selection algorithm is an **independent reimplementation** of the cartographic decision tree published by Snyder (1987) and formalized by Šavrič et al. (2016).\n\nCreated by: [Thomas Ansart — Atelier de cartographie de Sciences Po](https://www.sciencespo.fr/cartographie/)\nLicense: ISC\n\n## Français\n\nBibliothèque TypeScript, sans dépendance, qui suggère des projections cartographiques adaptées à partir d'une bounding box (bbox), y compris des projections nationales officielles.\n\nL'algorithme de sélection est une **réimplémentation indépendante** de l'arbre de décision cartographique publié par Snyder (1987) et formalisé par Šavrič et al. (2016).\n\nCreated by: [Thomas Ansart — Atelier de cartographie de Sciences Po](https://www.sciencespo.fr/cartographie/)\nLicense: ISC\n\n## Installation\n\n```bash\nnpm install @ateliercartographie/proj-suggest\n```\n\n## Quick usage\n\n```ts\nimport { suggest_projections, validate_bbox } from '@ateliercartographie/proj-suggest';\nimport type { BBox } from '@ateliercartographie/proj-suggest';\n\n// Define a bbox [lon_min, lat_min, lon_max, lat_max]\nconst bbox: BBox = [-5, 41, 10, 51]; // Metropolitan France\n\nconst validation = validate_bbox(bbox);\nif (!validation.valid) {\n\tthrow new Error(`Invalid bbox: ${validation.errors.join(' | ')}`);\n}\n\n// Get all suggestions in a single call\nconst { national, generic } = suggest_projections(bbox);\n\nconsole.log(national);\n// [\n//   {\n//     id: 'france', epsg: '2154', projection: 'lambert93',\n//     bbox: [-6, 41.2, 10.4, 51.6],\n//     proj4: '+proj=lcc +lat_0=46.5 +lon_0=3 +lat_1=49 +lat_2=44 ...',\n//     d3: { projection: 'geoConicConformal', rotate: [-3, 0], parallels: [44, 49] },\n//     share: 0.93, ratio: 1.1, within: true\n//   }\n// ]\n\nconsole.log(generic);\n// [\n//   {\n//     id: 'albers_conic', name: 'Albers Conic', scale: ['region'], shape: 'round', equalarea: true,\n//     proj4: { string: '+proj=aea +lon_0=2.5 +lat_1=47.67 +lat_2=44.33 +lat_0=46 ...' },\n//     d3: { projection: 'geoAlbers', rotate: [-2.5, 0], parallels: [44.33, 47.67] }\n//   },\n//   { id: 'lambert_conformal_conic', ... },\n//   { id: 'equidistant_conic', ... }\n// ]\n\n// Without national projections\nconst { generic: genericOnly } = suggest_projections(bbox, { national: false });\n```\n\n## API\n\n### `suggest_projections(bbox: BBox | BBox[], options?: SuggestOptions): ProjectionSuggestions`\n\nMain entry point. Returns a structured object with the matching national projections (given priority) and the generic projections produced by the decision tree.\n\nAccepts either a single bbox or **an array of per-feature bboxes** (one per entity). In the latter case, the bboxes are first reduced to a single representative bbox — detached territories (Alaska, French overseas territories…) that would artificially inflate the extent are discarded — before matching takes place. The details of the reduction are exposed on the `reduced` field. See [`representative_bbox`](#representative_bboxboxes-bbox-options-reduceoptions-representativebbox) and the [Multi-bbox reduction](#multi-bbox-reduction) section.\n\n### `representative_bbox(boxes: BBox[], options?: ReduceOptions): RepresentativeBBox`\n\nReduces an array of per-feature bboxes to a single representative bbox by discarding minor detached territories. Useful when a dataset's overall bbox is misleading because of far-flung territories (USA + Alaska/Hawaii/Puerto Rico, France + overseas territories…).\n\n### `suggest_generic_projections(bbox: BBox): ResolvedProjection[]`\n\nReturns only the generic projections suggested for the given bbox.\n\n### `match_national_projections(bbox: BBox): MatchedCountry[]`\n\nReturns the countries whose national projection matches the reference bbox.\n\n### `validate_bbox(bbox: BBox): BBoxValidation`\n\nValidates a bbox before calling the suggestion functions. Returns an object:\n\n- `valid`: `true` if the bbox is valid\n- `errors`: array of detailed error messages\n\nChecks performed:\n\n- 4 finite values (`number`, no `NaN` or `Infinity`)\n- `lon_min` and `lon_max` within [−180, 180]\n- `lat_min` and `lat_max` within [−90, 90]\n- `lat_min <= lat_max`\n- non-degenerate bbox (non-zero width and height)\n\nNote: `lon_min > lon_max` is allowed and interpreted as crossing the antimeridian (±180°).\n\n### `get_intersecting_countries(bbox: BBox): MatchedCountry[]`\n\nReturns all countries whose bbox intersects the reference bbox, along with the intersection metrics (`share`, `ratio`, `within`), without applying any matching filter.\n\n### Types\n\n```ts\ntype BBox = [number, number, number, number]; // [lon_min, lat_min, lon_max, lat_max] in EPSG:4326\n\ninterface SuggestOptions extends ReduceOptions {\n\tnational?: boolean; // Include national projections (default: true)\n}\n\ninterface ProjectionSuggestions {\n\tnational: MatchedCountry[]; // Matching national projections (given priority)\n\tgeneric: ResolvedProjection[]; // Generic projections produced by the decision tree\n\treduced?: RepresentativeBBox; // Present only if an array of bboxes was provided\n}\n\ninterface ReduceOptions {\n\tdetach_gap?: number; // Max gap (degrees) between two bboxes belonging to the same landmass (default: 3)\n\tretain?: number; // Minimum share of area to keep in the result (default: 0.85)\n}\n\ninterface RepresentativeBBox {\n\tbbox: BBox; // The reduced bbox, used for the suggestion\n\tkept: number; // Number of features kept\n\toutliers: BBox[]; // Bboxes of the discarded features (detached territories)\n\ttrimmed: boolean; // false ⇒ bbox encompasses everything (nothing discarded)\n}\n\ninterface BBoxValidation {\n\tvalid: boolean;\n\terrors: string[];\n}\n\n/** Usage with proj4js: `proj4(result.proj4.string, [lon, lat])` */\ninterface Proj4Usage {\n\tstring: string; // Full proj4 string with parameters computed from the bbox\n}\n\n/**\n * Usage with d3-geo / d3-geo-projection.\n * If `snippet` is present, use this code instead of the individual parameters.\n */\ninterface D3Usage {\n\tprojection: string; // Factory function name, e.g. \"geoAlbers\", \"geoOrthographic\"\n\trotate?: [number, number] | [number, number, number]; // .rotate([λ, φ]) or .rotate([λ, φ, γ])\n\tcenter?: [number, number]; // .center([lon, lat])\n\tparallels?: [number, number]; // .parallels([lat1, lat2])\n\tsnippet?: string; // Manual construction code (interrupted projections, etc.)\n}\n\ninterface ResolvedProjection {\n\tid: string;\n\tname?: string;\n\tscale: ScaleType[]; // 'world' | 'hemisphere' | 'region' | 'local'\n\tshape: ShapeType; // 'rectangular' | 'round' | 'discontinuous' | 'rectangle'\n\tequalarea?: boolean;\n\tproj4: Proj4Usage | null; // null if proj4js does not support this projection\n\td3: D3Usage | null; // null if there is no native d3 equivalent\n}\n\ninterface MatchedCountry {\n\tid: string;\n\tepsg: string; // EPSG code ('2154'), 'AUTHORITY:code' ('ESRI:102025'), or '' when unregistered\n\tprojection: string;\n\tbbox: BBox;\n\tproj4: string; // Full proj4 string with geographically correct lon_0/lat_0\n\td3: D3Usage; // d3-geo config\n\tshare: number; // Share of the country bbox covered by the intersection (0-1)\n\tratio: number; // Area ratio of reference bbox / country bbox\n\twithin: boolean; // Is the reference bbox entirely contained within the country bbox?\n}\n```\n\n### Limitations of `validate_bbox`\n\n`validate_bbox` only checks the geometric and numeric validity of the bbox. Some cases remain outside its scope:\n\n- A valid bbox that is not very meaningful cartographically (e.g. an extremely small area)\n- Coordinates outside the intended EPSG:4326 usage but numerically within the allowed ranges\n\nIn these cases, the validation function may return `valid: true`, even though the suggestions remain technically computable but potentially not very useful.\n\n---\n\n## Available generic projections\n\nFor scales other than `world`, the library can return the following projections depending on the bbox's characteristics.\n\n| Projection                        | Equal area | Scale                     | Aspect ratio                                           | Shape         |\n| --------------------------------- | :--------: | -------------------------- | ------------------------------------------------------ | ------------- |\n| Orthographic                      |            | hemisphere                  | all¹                                                  | round         |\n| Lambert Azimuthal Equal Area      |     ✓      | hemisphere · region         | hem.: all¹ · reg.: square, landscape                   | round         |\n| Azimuthal Equidistant             |            | hemisphere · region         | hem.: all¹ · reg.: landscape²                         | discontinuous   |\n| Mercator                          |            | hemisphere · region · local | hem.: all³ · reg.: landscape⁴ · loc.: square, landscape | rectangular |\n| Cylindrical Equal Area            |     ✓      | hemisphere · region         | hem.: all³ · reg.: landscape⁴                         | rectangular |\n| Equirectangular                   |            | hemisphere · region · local | hem.: all³ · reg.: landscape⁴ · loc.: square, landscape | rectangular |\n| Albers Conic                      |     ✓      | region                      | landscape⁵                                               | round         |\n| Lambert Conformal Conic           |            | region                      | landscape⁵                                               | round         |\n| Equidistant Conic                 |            | region                      | square · landscape⁵                                       | round         |\n| Stereographic                     |            | region                      | square · landscape²                                       | round         |\n| Transverse Cylindrical Equal Area |     ✓      | region · local              | reg.: portrait · loc.: all                          | rectangular |\n| Transverse Mercator               |            | region · local              | reg.: portrait · loc.: all                          | round         |\n| Cassini                           |            | region                      | portrait                                               | round         |\n\n> ¹ Outside the tropical zone · only if the bbox width ≤ 180° for Orthographic.\n> ² Polar zone (centroid |lat| > 75°).\n> ³ Zone entirely within the tropics (|lat| < 23.44°).\n> ⁴ Equatorial zone (centroid |lat| < 15°) or tropical.\n> ⁵ Temperate zone (outside the equator and outside polar zones).\n\n> **Shape**: _round_ — oval or circular outline · _rectangular_ — map in a full frame · _discontinuous_ — map with geographic interruptions.\n\n---\n\n## Projection suggestion algorithm\n\nThe algorithm implemented in `suggest_generic_projections` follows a cascading logic that starts from the provided bbox to determine the scale, the aspect ratio, and the geographic position of the area, then selects the most suitable projections.\n\n### Step 1 — Determine the scale\n\nThe spherical area of the bbox is computed and compared to the total surface of the Earth (4π steradians). This ratio, called `earth_share`, determines the scale:\n\n| `earth_share`   | Scale          |\n| --------------- | -------------- |\n| ≥ 2/3 (~66 %)   | **world**      |\n| ≥ 1/6 (~17 %)   | **hemisphere** |\n| ≥ 1/200 (0.5 %) | **region**     |\n| < 1/200         | **local**      |\n\n### Step 2 — Determine the aspect ratio\n\nThe height/width ratio of the bbox (in degrees) classifies its shape:\n\n| Ratio (h/w)    | Type          |\n| -------------- | ------------- |\n| ≤ 0.8          | **landscape** |\n| ≥ 1.25         | **portrait**  |\n| in between     | **square**    |\n\n### Step 3 — Compute the centering parameters\n\nFor every scale except `world`, the bbox centroid is computed. The projection parameters are:\n\n- **`lon`**: longitude of the centroid\n- **`lat`**: latitude of the centroid\n- **`lat_1`, `lat_2`**: standard parallels, computed symmetrically around the centroid\n\nThe calculation of the standard parallels uses a different interval depending on the position:\n\n- **Polar zone** (|centroid latitude| > 75°) or **equatorial zone** (|centroid latitude| < 15°): the interval is **1/4** of the bbox height\n- **Other zones**: the interval is **1/6** of the bbox height\n\n### Step 4 — Cascading selection based on scale × shape × position\n\nThe projection selection follows a decision tree combining scale and aspect ratio. Geographic position (polar, tropical, equatorial) refines the choice.\n\n#### `world` scale\n\nNo centering parameter is needed. All available world projections are returned: Equal Earth, Bertin 1953, Interrupted Mollweide, etc.\n\n#### `hemisphere` scale\n\nRegardless of the aspect ratio (`square`, `landscape`, `portrait`):\n\n- **If the area lies entirely within the tropics** (|lat_min| < 23.44° and |lat_max| < 23.44°): the centering latitude is set to 0° and the selected projections are **Mercator**, **Cylindrical Equal Area**, **Equirectangular** — suited to low distortion in the equatorial zone.\n- **Otherwise**:\n  - If the width is ≤ 180°: **Orthographic** is added (the bbox fits within a visible hemisphere).\n  - In all cases: **Lambert Azimuthal Equal Area**, **Azimuthal Equidistant**.\n\n#### `region` scale — `square`\n\n- If close to the poles (|centroid lat| > 75°): the latitude is set to ±90°.\n- If close to the equator (|centroid lat| < 15°): the latitude is set to 0°.\n- Projections: **Lambert Azimuthal Equal Area**, **Stereographic**, **Equidistant Conic**.\n\n#### `region` scale — `landscape`\n\n- **Close to the poles** (|centroid lat| > 75°): latitude set to ±90°, azimuthal projections — **LAEA**, **Stereographic**, **Azimuthal Equidistant**.\n- **Equator or tropics** (|centroid lat| < 15° or a zone entirely within the tropics): latitude set to 0°, cylindrical projections — **Cylindrical Equal Area**, **Mercator**, **Equirectangular**.\n- **Temperate zones** (default case): conic projections — **Albers Conic**, **Lambert Conformal Conic**, **Equidistant Conic**. These conic projections are well suited to mid-latitude zones with an east-west extent.\n\n#### `region` scale — `portrait`\n\n- Transverse projections, suited to areas with a north-south extent: **Transverse Cylindrical Equal Area**, **Transverse Mercator**, **Cassini**.\n\n#### `local` scale — `portrait`\n\n- **Transverse Cylindrical Equal Area**, **Transverse Mercator**.\n\n#### `local` scale — `square` or `landscape`\n\nNo specific filter on identifiers. All projections compatible with the `local` scale are returned (Equirectangular, Mercator, Transverse Mercator, Transverse CEA).\n\n### Decision tree summary\n\n```\nbbox\n ├── earth_share ≥ 2/3 → world → all world projections\n └── earth_share < 2/3\n      ├── centering + standard parallels computed\n      │\n      ├── hemisphere (earth_share ≥ 1/6)\n      │    ├── tropical zone → Mercator, CEA, Equirectangular (lat=0)\n      │    └── otherwise → Orthographic (if ≤180°), LAEA, Azimuthal Equidistant\n      │\n      ├── region (earth_share ≥ 1/200)\n      │    ├── square → LAEA, Stereographic, Equidistant Conic\n      │    ├── landscape\n      │    │    ├── pole → LAEA, Stereographic, Azimuthal Equidistant (lat=±90)\n      │    │    ├── tropics → CEA, Mercator, Equirectangular (lat=0)\n      │    │    └── temperate → Albers, Lambert CC, Equidistant Conic\n      │    └── portrait → Transverse CEA, Transverse Mercator, Cassini\n      │\n      └── local (earth_share < 1/200)\n           ├── portrait → Transverse CEA, Transverse Mercator\n           └── square/landscape → all local projections\n```\n\n---\n\n## Matching against national projections\n\nThe algorithm implemented in `match_national_projections` compares the reference bbox with the bboxes of countries that have an official national projection. Three metrics are computed:\n\n### Metrics\n\n1. **`share`** — Intersection share: the area of the intersection between the reference bbox and the country bbox, relative to the area of the country bbox. A value between 0 and 1. A `share` of 0.9 means that 90% of the country's bbox is covered by the reference bbox.\n\n2. **`ratio`** — Area ratio: the area of the reference bbox divided by the area of the country bbox. A ratio of 1 means the areas are identical; a ratio of 3 means the reference bbox is 3 times the size of the country bbox.\n\n3. **`within`** — Containment: true if the reference bbox is entirely contained within the country bbox.\n\nAll areas are computed in spherical coordinates (steradians) to account for the convergence of meridians.\n\n### Matching criteria\n\nA national projection is considered a match if:\n\n- **(1) `share` ≥ 0.75** AND **(2) `ratio` < 2** — the reference bbox covers at least 75% of the country and is no more than 2 times its size.\n- **OR (3) `within` = true** — the reference bbox is entirely contained within the country bbox, regardless of its size.\n\n```\nMatch = ( share ≥ 0.75 AND ratio < 2 ) OR within\n```\n\n### Available countries\n\n| ID             | EPSG        | Projection                   |\n| -------------- | ----------- | ---------------------------- |\n| eu             | 3035        | Lambert Azimuthal Equal Area |\n| france         | 2154        | Lambert 93                   |\n| uk             | 27700       | Transverse Mercator          |\n| ireland        | 2157        | Transverse Mercator          |\n| switzerland    | 2056        | Swiss Oblique Mercator       |\n| brazil         | 10857       | Albers Equal Area            |\n| belgium        | 31370       | Lambert Conic Conformal      |\n| netherlands    | 28992       | Stereographic                |\n| germany        | 25832       | Transverse Mercator (UTM 32) |\n| spain          | —           | Lambert Conic Conformal      |\n| canary_islands | —           | Lambert Conic Conformal      |\n| usa            | 5070        | Albers Equal Area            |\n| canada         | 3347        | Lambert Conic Conformal      |\n| mexico         | 6372        | Lambert Conic Conformal      |\n| australia      | 9473        | Albers Equal Area            |\n| india          | 7755        | Lambert Conic Conformal      |\n| japan          | —           | Albers Equal Area            |\n| china          | ESRI:102025 | Albers Equal Area            |\n| russia         | 3576        | Lambert Azimuthal Equal Area |\n\n`—`: the projection has no registered code; `epsg` is then an empty string.\n\n**Spain** — Lambert conic conformal on ETRS89, mandated by Real Decreto 1071/2007 (art. 5) for maps at 1:500,000 and smaller, with the parameters set by the IGN for the _Atlas Nacional de España_ (J. J. Alonso, [_Proyecciones cartográficas en los mapas del Atlas Nacional de España_](https://www.ign.es/web/resources/docs/IGNCnig/ProyeccionesMapasANE.pdf), IGN, 2014):\n\n- `spain` — mainland, Balearic Islands, Ceuta and Melilla: origin 40° N 3° W, standard parallels 42° 50′ N and 37° 07′ N, false easting and northing 600,000 m.\n- `canary_islands` — tangent cone at 28° 30′ N, central meridian 16° W, false easting and northing 300,000 m.\n\nThe Canary Islands are kept out of the `spain` bbox: including them would stretch it over northern Morocco. A dataset covering all of Spain matches `spain` when passed as per-feature bboxes (the islands are discarded as a detached territory, see [Multi-bbox reduction](#multi-bbox-reduction)); a dataset covering only the Canaries matches `canary_islands`.\n\n---\n\n## Multi-bbox reduction\n\nA single bbox is a *lossy* proxy for a geometry. For a country with detached territories, the bbox that encompasses **everything** vastly over-represents the main landmass and causes the national matching to fail.\n\nThe textbook case: the `cb_2018_us_state_20m` shapefile (US states). Its total extent is `[-179.17, 17.91, 179.77, 71.35]` — about 358° of longitude wide, because Alaska's Aleutian Islands cross the antimeridian. `suggest_projections` would classify it at \"world\" scale and would never suggest the US Albers projection (EPSG:5070).\n\nModern spatial formats (GeoParquet, FlatGeobuf, GeoPackage R-tree index…) already store a per-feature bbox as a spatial index proxy. By consuming this array, `representative_bbox` recovers the dominant landmass without touching the full geometry.\n\n### Algorithm\n\n0. **Antimeridian** — Features whose bbox is more than 180° wide cross the date line; their true extent cannot be recovered from the extremes alone, so they are discarded outright (the Alaska case).\n1. **Connected components** — Two features join the same landmass when the gap between their bboxes is ≤ `detach_gap` (default 3°). This single physical threshold groups a continuous landmass (adjacent entities → zero gap) and absorbs islands close to a strait (Corsica, ~0.7° from the mainland), while still isolating overseas territories (tens of degrees away).\n2. **Discarding** — The largest component (by spherical bbox area) is kept, and detached components are discarded as long as their cumulative area stays under the `1 − retain` budget. Weighting by area correctly ranks a large mainland ahead of small territories, even when the dataset only has a handful of features.\n\nA safeguard short-circuits **scattered** data: if no component accounts for at least `retain` of the total area, there is no dominant subject to extract, and nothing is trimmed (a multi-continent world map remains a world map).\n\n### Example\n\n```ts\nimport { suggest_projections, representative_bbox } from '@ateliercartographie/proj-suggest';\nimport type { BBox } from '@ateliercartographie/proj-suggest';\n\n// One bbox per state/territory (as provided by a GeoParquet, FlatGeobuf…)\nconst boxes: BBox[] = [\n\t/* …, */ [-124.41, 32.53, -114.14, 42.01] /* California, …48 contiguous states… */,\n\t[-179.17, 51.22, 179.77, 71.35], // Alaska (crosses the antimeridian)\n\t[-160.25, 18.92, -154.81, 22.23], // Hawaii\n\t[-67.96, 17.91, -65.22, 18.51] // Puerto Rico\n];\n\nconst { national, reduced } = suggest_projections(boxes);\n\nconsole.log(reduced);\n// {\n//   bbox: [-124.73, 24.5, -66.95, 49.38],  // ≈ CONUS (48 contiguous states + DC)\n//   kept: 49,\n//   outliers: [ /* Hawaii, Puerto Rico, Alaska */ ],\n//   trimmed: true\n// }\n\nconsole.log(national.some((d) => d.id === 'usa')); // true → Albers EPSG:5070\n\n// Tuning: detachment threshold and retained share\nrepresentative_bbox(boxes, { detach_gap: 5, retain: 0.9 });\n```\n\n> **Performance.** Binning the features runs in O(n); clustering the components runs in O(m²), where `m` is the number of *occupied cells* (≪ n) — dense datasets such as the ~35,000 French communes reduce to a small `m`. For sparse inputs covering the whole globe at high resolution, a spatial index over the nodes would lift this ceiling (a deferred optimization).\n\n---\n\n## Examples\n\n### Suggestion for metropolitan France\n\n```ts\nimport { suggest_projections, validate_bbox } from '@ateliercartographie/proj-suggest';\n\nconst france: BBox = [-5, 41, 10, 51];\n\nconst validation = validate_bbox(france);\nif (!validation.valid) {\n\tthrow new Error(`Invalid bbox: ${validation.errors.join(' | ')}`);\n}\n\nconst { national, generic } = suggest_projections(france);\n\n// National projections: proj4 and d3 ready to use\nconsole.log(national[0].proj4); // '+proj=lcc +lat_0=46.5 +lon_0=3 +lat_1=49 +lat_2=44 ...'\nconsole.log(national[0].d3); // { projection: 'geoConicConformal', rotate: [-3, 0], parallels: [44, 49] }\n\n// Generic projections: results calibrated on the bbox\nconsole.log(generic[0].id); // 'albers_conic'\nconsole.log(generic[0].proj4.string); // '+proj=aea +lon_0=2.5 +lat_0=46 +lat_1=44.33 +lat_2=47.67 ...'\nconsole.log(generic[0].d3); // { projection: 'geoAlbers', rotate: [-2.5, 0], parallels: [44.33, 47.67] }\n```\n\n### Detailed validation example\n\n```ts\nimport { validate_bbox } from '@ateliercartographie/proj-suggest';\n\nconst invalidBbox = [10, 60, 10, 40] as const;\nconst result = validate_bbox(invalidBbox as [number, number, number, number]);\n\nif (!result.valid) {\n\tconsole.error(result.errors);\n\t// [\n\t//   'lat_min (60) must be ≤ lat_max (40).',\n\t//   'lon_min and lon_max are equal — bbox has no width.'\n\t// ]\n}\n```\n\n### Suggestion for the whole world\n\n```ts\nconst world: BBox = [-180, -90, 180, 90];\n\nsuggest_projections(world);\n// → generic: Equal Earth, Equirectangular, Mercator, Bertin 1953, Gall-Peters, Times,\n//   Bonne, Atlantis, Mollweide Interrupted, Mollweide Interrupted Oceans, LAEA\n```\n\n### Suggestion for a polar area\n\n```ts\nconst arctic: BBox = [-180, 75, 180, 90];\n\nsuggest_projections(arctic);\n// → generic: LAEA (lat=90), Stereographic (lat=90), Azimuthal Equidistant (lat=90)\n```\n\n### Suggestion for a tropical area elongated east-west\n\n```ts\nconst tropics: BBox = [-30, -10, 50, 10];\n\nsuggest_projections(tropics);\n// → generic: Mercator (lat=0), Cylindrical Equal Area (lat=0), Equirectangular (lat=0)\n```\n\n### Suggestion for a portrait-oriented area (elongated north-south)\n\n```ts\nconst chile: BBox = [-76, -56, -66, -17];\n\nsuggest_projections(chile);\n// → generic: Transverse Cylindrical Equal Area, Transverse Mercator, Cassini\n```\n\n### Checking intersection without filtering\n\n```ts\nimport { get_intersecting_countries } from '@ateliercartographie/proj-suggest';\n\nconst bbox: BBox = [0, 45, 12, 55];\n\nget_intersecting_countries(bbox);\n// → All countries whose bbox intersects [0, 45, 12, 55],\n//   with share, ratio and within computed for each country.\n//   Useful for understanding why a country is (or isn't) matched.\n```\n\n### Using proj4js\n\n```ts\nimport proj4 from 'proj4';\nimport { suggest_projections } from '@ateliercartographie/proj-suggest';\n\nconst bbox: BBox = [-20, 35, 30, 65]; // Europe\nconst { generic, national } = suggest_projections(bbox);\n\n// Generic projections — proj4 is null for d3-only projections\nconst first = generic.find((d) => d.proj4 !== null);\nif (first) {\n\tconst [x, y] = proj4(first.proj4.string).forward([2.35, 48.86]); // Paris\n}\n\n// National projections — proj4 is always a directly usable string\nif (national.length > 0) {\n\tconst [x, y] = proj4(national[0].proj4).forward([2.35, 48.86]);\n}\n```\n\n### Using d3-geo\n\n```ts\nimport * as d3 from 'd3';\nimport * as d3geo from 'd3-geo-projection';\nimport { suggest_projections } from '@ateliercartographie/proj-suggest';\n\nconst bbox: BBox = [-20, 35, 30, 65]; // Europe\nconst { generic, national } = suggest_projections(bbox);\n\n// Generic projections\nconst suggestion = generic[0];\nif (suggestion.d3) {\n\tconst { projection, rotate, parallels, snippet } = suggestion.d3;\n\n\tif (snippet) {\n\t\t// Special case: a projection that must be built manually (e.g. mollweide_ocean)\n\t\t// Evaluate or display the snippet as construction documentation\n\t\tconsole.log(snippet);\n\t} else {\n\t\tconst factory = d3[projection] ?? d3geo[projection];\n\t\tconst proj = factory();\n\t\tif (rotate) proj.rotate(rotate);\n\t\tif (parallels) proj.parallels(parallels);\n\t}\n}\n\n// National projections — d3 is always present\nconst { projection, rotate, parallels } = national[0].d3;\nconst factory = d3[projection] ?? d3geo[projection];\nconst proj = factory();\nif (rotate) proj.rotate(rotate);\nif (parallels) proj.parallels(parallels);\n```\n\n## Development\n\n```bash\npnpm install\npnpm dev       # Development server with an interactive playground\npnpm test      # Unit tests (vitest)\npnpm package   # Build the library\n```\n\n## References\n\nThe projection selection algorithm is based on the decision tree published by:\n\n- Snyder, J. P. (1987). _Map Projections — A Working Manual_. USGS Professional Paper 1395, p. 34–35. [doi:10.3133/pp1395](https://doi.org/10.3133/pp1395)\n- Šavrič, B., Jenny, B. and Jenny, H. (2016). Projection Wizard – An online map projection selection tool. _The Cartographic Journal_, 53(2), p. 177–185. [doi:10.1080/00087041.2015.1131938](https://doi.org/10.1080/00087041.2015.1131938)\n\nThe online tool [Projection Wizard](https://projectionwizard.org) by Bojan Šavrič served as an inspiration. This library is an **independent reimplementation** of the published decision tree: the code was written from scratch in TypeScript as a framework-agnostic developer library and an original national-projection matching module.\n\n## License\n\nISC\n","readmeFilename":"README.md"}