@tileflow/static 0.1.0-alpha.20 → 0.1.0-alpha.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,43 +20,133 @@ import {
20
20
  const scene = validateStaticScene({
21
21
  map: 'madrid',
22
22
  theme: 'dark',
23
- camera: {type: 'center', center: [-3.7038, 40.4168], zoom: 12},
24
- size: {width: 1200, height: 800},
23
+ format: 'webp',
24
+ camera: {type: 'auto', padding: 24},
25
+ size: {width: 600, height: 400, dpr: 2},
25
26
  overlays: [marker({coordinate: [-3.7038, 40.4168]})],
26
27
  });
27
28
 
28
29
  if (!scene.ok) throw new Error(scene.error);
29
30
 
30
- await precacheStaticMap(scene.scene, {
31
+ const result = await precacheStaticMap(scene.scene, {
31
32
  apiKey: process.env.TILEFLOW_API_KEY,
32
33
  idempotencyKey: createStaticMapIdempotencyKey(),
33
34
  });
35
+
36
+ console.log(result.attribution.entries);
37
+ ```
38
+
39
+ `width` and `height` are logical CSS pixels. `dpr` accepts 1 or 2 and defaults to 1, so a 600 by
40
+ 400 scene at DPR 2 produces a 1200 by 800 image without changing camera, padding, or overlay paint
41
+ sizes. Logical dimensions may reach 2048 per axis; the physical budget permits 4096 by 4096 output,
42
+ or 16,777,216 total pixels.
43
+
44
+ `format` accepts `png`, `jpeg`, or `webp` and defaults to PNG. PNG's canonical default stays omitted,
45
+ so explicit and omitted PNG requests share identity. JPEG and WebP use a fixed encoder quality;
46
+ there is no public quality option.
47
+
48
+ ## Attribution
49
+
50
+ New renders default to `attribution: {mode: 'embedded', position: 'auto'}` without adding that
51
+ omitted default to the normalized scene. Hosted derives notices from the sources referenced by the
52
+ exact selected Style and embeds a safe, compact block. Automatic placement prefers an unobstructed
53
+ corner, then the least interfering corner among declared request overlays. Explicit center and
54
+ bounds cameras never move.
55
+
56
+ Choose a corner when composition requires it:
57
+
58
+ ```ts
59
+ attribution: {mode: 'embedded', position: 'bottom-left'}
60
+ ```
61
+
62
+ Or request structured external attribution:
63
+
64
+ ```ts
65
+ const result = await createStaticMap(
66
+ {...scene.scene, attribution: {mode: 'external'}},
67
+ {
68
+ apiKey: process.env.TILEFLOW_API_KEY,
69
+ idempotencyKey: createStaticMapIdempotencyKey(),
70
+ },
71
+ );
72
+
73
+ for (const entry of result.attribution.entries) {
74
+ renderAttributionText(entry.text, entry.links);
75
+ }
76
+ ```
77
+
78
+ External mode leaves the image unchanged and returns `position: null`. The caller must retain and
79
+ display every returned entry beside the image. Entries contain bounded plain text, safe HTTP(S)
80
+ links, and `platform-notice` or `team-declared` authority; they never contain source HTML or private
81
+ resource IDs. Embedded results return the resolved corner. Changing mode or requested position
82
+ changes scene and artifact identity.
83
+
84
+ ## Automatic framing
85
+
86
+ `camera.type: 'auto'` fits the complete nominal footprint of every declared marker, circle, line,
87
+ and polygon. It works with Mercator and Globe styles when the geometry can be shown. It never clips
88
+ silently.
89
+
90
+ ```ts
91
+ camera: {
92
+ type: 'auto',
93
+ padding: {top: 24, right: 240, bottom: 24, left: 24},
94
+ maxZoom: 16,
95
+ bearing: 0,
96
+ }
34
97
  ```
35
98
 
99
+ Padding uses logical CSS pixels. A number applies to every side; omitted sides in an object are
100
+ zero. Without `padding`, Tileflow uses `min(32, ceil(5% * min(width, height)))`. `maxZoom` defaults
101
+ to 16 and `bearing` to 0. A single position still honors asymmetric padding, so its apparent center
102
+ can move away from the viewport center.
103
+
104
+ Point-like overlays around ±180° use the shortest longitude interval. Tileflow never reinterprets
105
+ vertices inside a line or polygon: an ambiguous crossing returns an error. Represent a correctly
106
+ cut `MultiLineString` or `MultiPolygon` as several `line` or `polygon` overlays with identical
107
+ styles; auto-fit may move each complete component to an adjacent world copy.
108
+
109
+ Current overlay primitives use MapLibre's GeoJSON pipeline and require
110
+ `abs(latitude) <= MAX_OVERLAY_LATITUDE` (`85.051129`). Tileflow rejects larger latitudes instead of
111
+ clamping them. A Globe basemap may display polar regions even though these overlay primitives cannot
112
+ yet be drawn there.
113
+
114
+ Deterministic request failures return `422`, set `retryable: false`, and preserve a stable code,
115
+ reason, and bounded details. `validateStaticScene` returns that document; request helpers throw
116
+ `StaticMapRequestError` either locally or from the Hosted response. The reasons distinguish an empty
117
+ scene, an ambiguous antimeridian path, an out-of-range overlay latitude, an insufficient viewport,
118
+ a Globe visibility conflict, an unresolvable camera, and failed post-fit containment. Correct the
119
+ scene; do not retry it unchanged. Unsupported attribution grammar or glyphs, excessive attribution,
120
+ and a block that cannot fit use the same non-retryable `422` boundary.
121
+
36
122
  The package has four deliberately separate surfaces, all available from the root for compatibility:
37
123
 
38
124
  - scene validation and normalization: `staticSceneSchema`, `validateStaticScene`, and
39
125
  `normalizeStaticScene`;
40
126
  - overlay authoring and MapLibre compilation: `line`, `circle`, `marker`, `polygon`, and
41
127
  `compileStaticOverlays`;
42
- - renderer identity: `createRenderManifest`, `validateStaticRenderManifest`,
43
- `hashRenderManifest`, and `hashStaticSceneRequest`;
128
+ - renderer identity: `createRenderManifestV1`, `createRenderManifestV2`,
129
+ `validateStaticRenderManifest`, `hashRenderManifest`, and `hashStaticSceneRequest`;
44
130
  - bounded Hosted transport: `prepareStaticMapRequest`, `createStaticMap`, `precacheStaticMap`, and
45
131
  `requestStaticMapUntilReady`.
46
132
 
47
133
  Applications that need only one surface can use `@tileflow/static/scene`, `/overlays`, `/manifest`,
48
134
  or `/client`. The root remains a compatibility entry point.
49
135
 
50
- `StaticRenderManifest` is the immutable input contract between a resolved Hosted style and a Static
51
- Maps renderer. Its top-level `mapId` identifies that resolved destination while `scene.map` retains
52
- the portable map name supplied by the application. It is unrelated to the runtime delivery manifest
53
- generated by application build or deploy workflows.
136
+ `StaticRenderManifest` is the versioned immutable input contract between a resolved Hosted style and
137
+ a Static Maps renderer. Manifest v1 remains the exact historical no-attribution contract; new Hosted
138
+ work uses strict v2 with resolved safe entries and immutable provenance. `createRenderManifest`
139
+ remains the v1 compatibility alias. Its top-level `mapId` identifies the resolved destination while
140
+ `scene.map` retains the portable map name supplied by the application. It is unrelated to the
141
+ runtime delivery manifest generated by application build or deploy workflows.
54
142
 
55
143
  Create one idempotency key per intentional create/precache action and retain it across network
56
144
  retries. Reusing the key with the same normalized scene returns the stored result; reusing it with a
57
145
  different scene returns 409. A successful paid operation reports `unitCost: 15`, shared
58
146
  `remainingUnits`, its durable `operationId`, cache reuse, hash, and immutable image URL. An
59
- unbounded Starter service reports `remainingUnits: null`. A live
147
+ unbounded Starter service reports `remainingUnits: null`. Creation helpers request strict result v2,
148
+ including `resultVersion: 2` and required attribution. They reject a legacy success rather than
149
+ claiming notices that were never produced. A live
60
150
  equal-hash render may return `202`; the helper polls with the same body/key within its bounded wait.
61
151
  `maxWaitMs` is the total client completion budget, including network, response parsing, and polling
62
152
  delays; `signal` cancels the same complete operation. The helper aborts in-flight work when either
@@ -69,7 +159,7 @@ the same normalized scene for its `sceneKey` and HTTP body, so equivalent omitte
69
159
  deduplicate consistently without duplicating the polling implementation.
70
160
 
71
161
  Free cannot initiate Static Maps work. Starter consumes 15 shared API units for each successful
72
- logical operation, including a distinct-key cache reuse. Downloading the immutable PNG consumes
73
- zero units.
162
+ logical operation, including a distinct-key cache reuse. Downloading an immutable PNG, JPEG, or WebP
163
+ consumes zero units.
74
164
 
75
165
  Docs: https://tileflow.dev/docs
@@ -0,0 +1,477 @@
1
+ import {
2
+ MAX_OVERLAY_LATITUDE,
3
+ jsonByteLength,
4
+ normalizeStaticCoordinate,
5
+ normalizeStaticOverlay,
6
+ roundNumber,
7
+ staticPaddingSchema,
8
+ staticSceneLimits,
9
+ staticSceneSchema,
10
+ stripUndefined
11
+ } from "./chunk-NM2JSFUF.js";
12
+
13
+ // src/auto-fit.ts
14
+ import { z } from "zod";
15
+ var autoFitOverlayReferenceSchema = z.object({
16
+ id: z.string().min(1).max(64),
17
+ index: z.number().int().min(0).max(23),
18
+ type: z.enum(["circle", "line", "marker", "polygon"])
19
+ }).strict();
20
+ var autoFitViewportSchema = z.object({
21
+ height: z.number().int().min(64).max(1280),
22
+ width: z.number().int().min(64).max(1280)
23
+ }).strict();
24
+ var autoFitDetailsSchema = z.object({
25
+ overlay: autoFitOverlayReferenceSchema.optional(),
26
+ padding: staticPaddingSchema.optional(),
27
+ projection: z.enum(["globe", "mercator"]).optional(),
28
+ requiredInsets: staticPaddingSchema.optional(),
29
+ ringIndex: z.number().int().min(0).max(15).optional(),
30
+ segmentIndex: z.number().int().min(0).max(1999).optional(),
31
+ viewport: autoFitViewportSchema.optional()
32
+ }).strict();
33
+ var staticOverlayInvalidDetailsSchema = z.object({
34
+ latitude: z.number().finite(),
35
+ limit: z.literal(MAX_OVERLAY_LATITUDE),
36
+ overlay: autoFitOverlayReferenceSchema
37
+ }).strict();
38
+ var autoFitErrorBase = {
39
+ error: z.string().min(1).max(512),
40
+ retryable: z.literal(false)
41
+ };
42
+ var staticMapRequestErrorResponseSchema = z.discriminatedUnion("code", [
43
+ z.object({
44
+ ...autoFitErrorBase,
45
+ code: z.literal("AUTO_FIT_EMPTY"),
46
+ reason: z.literal("NO_FITTABLE_OVERLAYS")
47
+ }).strict(),
48
+ z.object({
49
+ ...autoFitErrorBase,
50
+ code: z.literal("AUTO_FIT_AMBIGUOUS"),
51
+ details: autoFitDetailsSchema,
52
+ reason: z.literal("ANTIMERIDIAN_WRAP_REQUIRED")
53
+ }).strict(),
54
+ z.object({
55
+ ...autoFitErrorBase,
56
+ code: z.literal("AUTO_FIT_IMPOSSIBLE"),
57
+ details: autoFitDetailsSchema.optional(),
58
+ reason: z.enum([
59
+ "GLOBE_NOT_SIMULTANEOUSLY_VISIBLE",
60
+ "INSUFFICIENT_VIEWPORT",
61
+ "CAMERA_UNRESOLVABLE",
62
+ "PROJECTED_FOOTPRINT_OUTSIDE_VIEWPORT"
63
+ ])
64
+ }).strict(),
65
+ z.object({
66
+ ...autoFitErrorBase,
67
+ code: z.literal("STATIC_OVERLAY_INVALID"),
68
+ details: staticOverlayInvalidDetailsSchema,
69
+ reason: z.literal("OVERLAY_LATITUDE_OUT_OF_RANGE")
70
+ }).strict(),
71
+ z.object({
72
+ ...autoFitErrorBase,
73
+ code: z.literal("STATIC_MAP_ATTRIBUTION_UNSUPPORTED"),
74
+ reason: z.enum(["UNSUPPORTED_DECLARATION", "UNSUPPORTED_GLYPH"])
75
+ }).strict(),
76
+ z.object({
77
+ ...autoFitErrorBase,
78
+ code: z.literal("STATIC_MAP_ATTRIBUTION_TOO_LARGE"),
79
+ reason: z.literal("CONTENT_LIMIT_EXCEEDED")
80
+ }).strict(),
81
+ z.object({
82
+ ...autoFitErrorBase,
83
+ code: z.literal("STATIC_MAP_ATTRIBUTION_DOES_NOT_FIT"),
84
+ reason: z.literal("BLOCK_DOES_NOT_FIT")
85
+ }).strict()
86
+ ]);
87
+ var StaticMapRequestError = class extends Error {
88
+ code;
89
+ details;
90
+ reason;
91
+ retryable = false;
92
+ status;
93
+ constructor(response, status = 422) {
94
+ super(response.error);
95
+ this.name = "StaticMapRequestError";
96
+ this.code = response.code;
97
+ this.details = "details" in response ? response.details : void 0;
98
+ this.reason = response.reason;
99
+ this.status = status;
100
+ }
101
+ };
102
+ var staticAutoFitPlanSchema = z.object({
103
+ bounds: z.tuple([
104
+ z.number().finite(),
105
+ z.number().finite().min(-90).max(90),
106
+ z.number().finite(),
107
+ z.number().finite().min(-90).max(90)
108
+ ]),
109
+ longitudeOffsets: z.array(z.number().int().min(-360).max(360)).max(24),
110
+ nominalInsets: staticPaddingSchema,
111
+ requiredInsets: staticPaddingSchema
112
+ }).strict();
113
+ function analyzeStaticAutoFit(scene) {
114
+ if (scene.camera.type !== "auto") {
115
+ throw new Error("Static auto-fit analysis requires an auto camera");
116
+ }
117
+ if (scene.overlays.length === 0) {
118
+ return failure({
119
+ code: "AUTO_FIT_EMPTY",
120
+ error: "Auto-fit requires at least one overlay",
121
+ reason: "NO_FITTABLE_OVERLAYS",
122
+ retryable: false
123
+ });
124
+ }
125
+ const components = [];
126
+ let south = Number.POSITIVE_INFINITY;
127
+ let north = Number.NEGATIVE_INFINITY;
128
+ let nominalExpansion = 0;
129
+ for (const [overlayIndex, overlay] of scene.overlays.entries()) {
130
+ const coordinates = overlayCoordinates(overlay);
131
+ const ambiguity = findAmbiguousSegment(overlay);
132
+ if (ambiguity) {
133
+ return failure({
134
+ code: "AUTO_FIT_AMBIGUOUS",
135
+ details: {
136
+ overlay: {
137
+ id: overlay.id ?? `overlay-${overlayIndex + 1}`,
138
+ index: overlayIndex,
139
+ type: overlay.type
140
+ },
141
+ ...ambiguity.ringIndex === void 0 ? {} : { ringIndex: ambiguity.ringIndex },
142
+ segmentIndex: ambiguity.segmentIndex
143
+ },
144
+ error: `Overlay overlays.${overlayIndex} requires an explicit antimeridian wrap decision`,
145
+ reason: "ANTIMERIDIAN_WRAP_REQUIRED",
146
+ retryable: false
147
+ });
148
+ }
149
+ const longitudes = coordinates.map(([longitude]) => longitude);
150
+ components.push({
151
+ east: Math.max(...longitudes),
152
+ overlayIndex,
153
+ west: Math.min(...longitudes)
154
+ });
155
+ for (const [, latitude] of coordinates) {
156
+ south = Math.min(south, latitude);
157
+ north = Math.max(north, latitude);
158
+ }
159
+ nominalExpansion = Math.max(nominalExpansion, nominalOverlayExpansion(overlay));
160
+ }
161
+ const longitudePlan = fitLongitudeComponents(components);
162
+ const nominalInset = Math.ceil(nominalExpansion);
163
+ const nominalInsets = allSides(nominalInset);
164
+ const padding = normalizedPadding(scene.camera.padding, scene.size);
165
+ if ([padding.bottom, padding.left, padding.right, padding.top].some(
166
+ (value) => !Number.isSafeInteger(value + nominalInset)
167
+ )) {
168
+ return failure({
169
+ code: "AUTO_FIT_IMPOSSIBLE",
170
+ details: {
171
+ padding,
172
+ viewport: { height: scene.size.height, width: scene.size.width }
173
+ },
174
+ error: "Auto-fit nominal footprint leaves no usable viewport",
175
+ reason: "INSUFFICIENT_VIEWPORT",
176
+ retryable: false
177
+ });
178
+ }
179
+ const requiredInsets = {
180
+ bottom: padding.bottom + nominalInset,
181
+ left: padding.left + nominalInset,
182
+ right: padding.right + nominalInset,
183
+ top: padding.top + nominalInset
184
+ };
185
+ if (requiredInsets.left + requiredInsets.right >= scene.size.width || requiredInsets.top + requiredInsets.bottom >= scene.size.height) {
186
+ return failure({
187
+ code: "AUTO_FIT_IMPOSSIBLE",
188
+ details: {
189
+ padding,
190
+ requiredInsets,
191
+ viewport: { height: scene.size.height, width: scene.size.width }
192
+ },
193
+ error: "Auto-fit nominal footprint leaves no usable viewport",
194
+ reason: "INSUFFICIENT_VIEWPORT",
195
+ retryable: false
196
+ });
197
+ }
198
+ return {
199
+ ok: true,
200
+ plan: {
201
+ bounds: [
202
+ roundNumber(longitudePlan.west),
203
+ roundNumber(south),
204
+ roundNumber(longitudePlan.east),
205
+ roundNumber(north)
206
+ ],
207
+ longitudeOffsets: longitudePlan.offsets,
208
+ nominalInsets,
209
+ requiredInsets
210
+ }
211
+ };
212
+ }
213
+ function failure(response) {
214
+ return { ...response, ok: false };
215
+ }
216
+ function overlayCoordinates(overlay) {
217
+ if (overlay.type === "circle" || overlay.type === "marker") {
218
+ return [overlay.coordinate];
219
+ }
220
+ if (overlay.type === "line") {
221
+ return overlay.coordinates;
222
+ }
223
+ return overlay.coordinates.flat();
224
+ }
225
+ function findAmbiguousSegment(overlay) {
226
+ if (overlay.type === "circle" || overlay.type === "marker") return null;
227
+ const paths = overlay.type === "line" ? [overlay.coordinates] : overlay.coordinates;
228
+ for (const [ringIndex, path] of paths.entries()) {
229
+ for (let segmentIndex = 0; segmentIndex < path.length - 1; segmentIndex += 1) {
230
+ const start = path[segmentIndex];
231
+ const end = path[segmentIndex + 1];
232
+ if (start && end && Math.abs(end[0] - start[0]) >= 180) {
233
+ return {
234
+ ...overlay.type === "polygon" ? { ringIndex } : {},
235
+ segmentIndex
236
+ };
237
+ }
238
+ }
239
+ }
240
+ return null;
241
+ }
242
+ function nominalOverlayExpansion(overlay) {
243
+ if (overlay.type === "circle" || overlay.type === "marker") {
244
+ return overlay.radius + overlay.strokeWidth;
245
+ }
246
+ if (overlay.type === "line") {
247
+ return overlay.width / 2 + 0.5;
248
+ }
249
+ return overlay.stroke && overlay.strokeWidth > 0 ? overlay.strokeWidth / 2 + 0.5 : 0;
250
+ }
251
+ function fitLongitudeComponents(components) {
252
+ const candidates = /* @__PURE__ */ new Set();
253
+ for (const component of components) {
254
+ candidates.add(toCircle(component.west));
255
+ candidates.add(toCircle(component.east));
256
+ }
257
+ let best;
258
+ for (const cut of [...candidates].sort((left, right) => left - right)) {
259
+ const assignedStarts = [];
260
+ let west = Number.POSITIVE_INFINITY;
261
+ let east = Number.NEGATIVE_INFINITY;
262
+ let valid = true;
263
+ for (const component of components) {
264
+ const width = component.east - component.west;
265
+ let start = toCircle(component.west);
266
+ if (start < cut) start += 360;
267
+ const end = start + width;
268
+ if (end > cut + 360 + 1e-9) {
269
+ valid = false;
270
+ break;
271
+ }
272
+ assignedStarts.push(start);
273
+ west = Math.min(west, start);
274
+ east = Math.max(east, end);
275
+ }
276
+ if (!valid) continue;
277
+ const span = east - west;
278
+ const canonicalWest2 = canonicalLongitude(west - 180);
279
+ const bestCanonicalWest = best ? canonicalLongitude(best.west - 180) : Number.POSITIVE_INFINITY;
280
+ if (!best || span < best.span - 1e-9 || Math.abs(span - best.span) <= 1e-9 && canonicalWest2 < bestCanonicalWest) {
281
+ best = { assignedStarts, east, span, west };
282
+ }
283
+ }
284
+ if (!best) {
285
+ const west = Math.min(...components.map((component) => component.west));
286
+ const east = Math.max(...components.map((component) => component.east));
287
+ return { east, offsets: components.map(() => 0), west };
288
+ }
289
+ const unshiftedWest = best.west - 180;
290
+ const canonicalWest = canonicalLongitude(unshiftedWest);
291
+ const globalOffset = canonicalWest - unshiftedWest;
292
+ const offsets = components.map((component, index) => {
293
+ const assignedLongitude = (best?.assignedStarts[index] ?? 0) - 180 + globalOffset;
294
+ return Math.round(assignedLongitude - component.west);
295
+ });
296
+ return {
297
+ east: canonicalWest + best.span,
298
+ offsets,
299
+ west: canonicalWest
300
+ };
301
+ }
302
+ function toCircle(longitude) {
303
+ const value = (longitude + 180) % 360;
304
+ return value < 0 ? value + 360 : value;
305
+ }
306
+ function canonicalLongitude(longitude) {
307
+ const value = ((longitude + 180) % 360 + 360) % 360;
308
+ return value - 180;
309
+ }
310
+ function allSides(value) {
311
+ return { bottom: value, left: value, right: value, top: value };
312
+ }
313
+ function normalizedPadding(padding, size) {
314
+ if (padding === void 0) {
315
+ return allSides(Math.min(32, Math.ceil(Math.min(size.width, size.height) * 0.05)));
316
+ }
317
+ if (typeof padding === "number") return allSides(padding);
318
+ return {
319
+ bottom: padding.bottom ?? 0,
320
+ left: padding.left ?? 0,
321
+ right: padding.right ?? 0,
322
+ top: padding.top ?? 0
323
+ };
324
+ }
325
+ function autoFitErrorResponse(failure2) {
326
+ const { ok: _ok, ...response } = failure2;
327
+ return response;
328
+ }
329
+ function findStaticOverlayLatitudeFailure(input) {
330
+ if (!input || typeof input !== "object" || Array.isArray(input)) return null;
331
+ const overlays = input.overlays;
332
+ if (!Array.isArray(overlays)) return null;
333
+ for (const [index, candidate] of overlays.entries()) {
334
+ if (index >= staticSceneLimits.maxOverlays) break;
335
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue;
336
+ const overlay = candidate;
337
+ const type = overlay.type;
338
+ if (!["circle", "line", "marker", "polygon"].includes(String(type))) continue;
339
+ const coordinates = type === "circle" || type === "marker" ? [overlay.coordinate] : overlayCoordinateCandidates(type, overlay.coordinates);
340
+ for (const coordinate of coordinates) {
341
+ if (!Array.isArray(coordinate) || coordinate.length !== 2 || typeof coordinate[1] !== "number" || !Number.isFinite(coordinate[1]) || Math.abs(coordinate[1]) <= MAX_OVERLAY_LATITUDE) {
342
+ continue;
343
+ }
344
+ const id = typeof overlay.id === "string" && overlay.id.trim() ? overlay.id.trim() : `overlay-${index + 1}`;
345
+ const response = {
346
+ code: "STATIC_OVERLAY_INVALID",
347
+ details: {
348
+ latitude: coordinate[1],
349
+ limit: MAX_OVERLAY_LATITUDE,
350
+ overlay: {
351
+ id: id.slice(0, 64),
352
+ index,
353
+ type
354
+ }
355
+ },
356
+ error: `Overlay overlays.${index} latitude ${coordinate[1]} exceeds the supported \xB1${MAX_OVERLAY_LATITUDE}\xB0 range`,
357
+ reason: "OVERLAY_LATITUDE_OUT_OF_RANGE",
358
+ retryable: false
359
+ };
360
+ return { ...response, ok: false };
361
+ }
362
+ }
363
+ return null;
364
+ }
365
+ function overlayCoordinateCandidates(type, value) {
366
+ if (!Array.isArray(value)) return [];
367
+ if (type === "line") {
368
+ return value.slice(0, staticSceneLimits.maxPathCoordinates);
369
+ }
370
+ return value.slice(0, 16).flatMap(
371
+ (ring) => Array.isArray(ring) ? ring.slice(0, staticSceneLimits.maxPathCoordinates) : []
372
+ );
373
+ }
374
+
375
+ // src/scene.ts
376
+ function validateStaticScene(input) {
377
+ const overlayLatitudeFailure = findStaticOverlayLatitudeFailure(input);
378
+ if (overlayLatitudeFailure) return overlayLatitudeFailure;
379
+ const parsed = staticSceneSchema.safeParse(input);
380
+ if (!parsed.success) {
381
+ return {
382
+ error: parsed.error.issues.map((issue) => `${issue.path.join(".") || "scene"}: ${issue.message}`).join("; "),
383
+ ok: false
384
+ };
385
+ }
386
+ if (jsonByteLength(parsed.data.overlays) > staticSceneLimits.maxGeoJsonBytes) {
387
+ return {
388
+ error: `overlays exceed ${staticSceneLimits.maxGeoJsonBytes} bytes`,
389
+ ok: false
390
+ };
391
+ }
392
+ const scene = normalizeParsedStaticScene(parsed.data);
393
+ if (scene.camera.type === "auto") {
394
+ const analysis = analyzeStaticAutoFit(scene);
395
+ if (!analysis.ok) return analysis;
396
+ }
397
+ return { ok: true, scene };
398
+ }
399
+ function normalizeStaticScene(scene) {
400
+ const overlayLatitudeFailure = findStaticOverlayLatitudeFailure(scene);
401
+ if (overlayLatitudeFailure) {
402
+ const { ok: _ok, ...response } = overlayLatitudeFailure;
403
+ throw new StaticMapRequestError(response);
404
+ }
405
+ const normalized = normalizeParsedStaticScene(staticSceneSchema.parse(scene));
406
+ if (normalized.camera.type === "auto") {
407
+ const analysis = analyzeStaticAutoFit(normalized);
408
+ if (!analysis.ok) {
409
+ throw new StaticMapRequestError(autoFitErrorResponse(analysis));
410
+ }
411
+ }
412
+ return normalized;
413
+ }
414
+ function normalizeParsedStaticScene(parsed) {
415
+ return stripUndefined({
416
+ ...parsed.attribution && Object.keys(parsed.attribution).length > 0 ? {
417
+ attribution: parsed.attribution.mode === "external" ? { mode: "external" } : stripUndefined({
418
+ mode: parsed.attribution.mode,
419
+ position: parsed.attribution.position
420
+ })
421
+ } : {},
422
+ camera: parsed.camera.type === "center" ? {
423
+ bearing: roundNumber(parsed.camera.bearing ?? 0),
424
+ center: normalizeStaticCoordinate(parsed.camera.center),
425
+ type: "center",
426
+ zoom: roundNumber(parsed.camera.zoom)
427
+ } : parsed.camera.type === "bounds" ? {
428
+ bearing: roundNumber(parsed.camera.bearing ?? 0),
429
+ bounds: parsed.camera.bounds.map(roundNumber),
430
+ padding: parsed.camera.padding ?? 32,
431
+ type: "bounds"
432
+ } : {
433
+ bearing: roundNumber(parsed.camera.bearing ?? 0),
434
+ maxZoom: roundNumber(parsed.camera.maxZoom ?? 16),
435
+ padding: normalizeAutoPadding(parsed.camera.padding, parsed.size),
436
+ type: "auto"
437
+ },
438
+ ...parsed.format && parsed.format !== "png" ? { format: parsed.format } : {},
439
+ map: parsed.map,
440
+ overlays: parsed.overlays.map(
441
+ (overlay, index) => normalizeStaticOverlay({
442
+ ...overlay,
443
+ id: overlay.id ?? `overlay-${index + 1}`
444
+ })
445
+ ),
446
+ size: {
447
+ dpr: parsed.size.dpr ?? 1,
448
+ height: parsed.size.height,
449
+ width: parsed.size.width
450
+ },
451
+ theme: parsed.theme
452
+ });
453
+ }
454
+ function normalizeAutoPadding(padding, size) {
455
+ if (padding === void 0) {
456
+ const value = Math.min(32, Math.ceil(Math.min(size.width, size.height) * 0.05));
457
+ return { bottom: value, left: value, right: value, top: value };
458
+ }
459
+ if (typeof padding === "number") {
460
+ return { bottom: padding, left: padding, right: padding, top: padding };
461
+ }
462
+ return {
463
+ bottom: padding.bottom ?? 0,
464
+ left: padding.left ?? 0,
465
+ right: padding.right ?? 0,
466
+ top: padding.top ?? 0
467
+ };
468
+ }
469
+
470
+ export {
471
+ staticMapRequestErrorResponseSchema,
472
+ StaticMapRequestError,
473
+ staticAutoFitPlanSchema,
474
+ analyzeStaticAutoFit,
475
+ validateStaticScene,
476
+ normalizeStaticScene
477
+ };
@@ -1,6 +1,7 @@
1
1
  import {
2
- normalizeStaticOverlay
3
- } from "./chunk-YUG3PBK2.js";
2
+ normalizeStaticOverlay,
3
+ roundNumber
4
+ } from "./chunk-NM2JSFUF.js";
4
5
 
5
6
  // src/overlays.ts
6
7
  function line(input) {
@@ -15,15 +16,23 @@ function marker(input) {
15
16
  function polygon(input) {
16
17
  return normalizeStaticOverlay({ ...input, type: "polygon" });
17
18
  }
18
- function compileStaticOverlays(overlays) {
19
+ function compileStaticOverlays(overlays, options = {}) {
20
+ if (options.longitudeOffsets !== void 0 && (options.longitudeOffsets.length !== overlays.length || options.longitudeOffsets.some(
21
+ (offset) => !Number.isInteger(offset) || Math.abs(offset) > 360 || offset % 360 !== 0
22
+ ))) {
23
+ throw new Error(
24
+ "Static overlay longitude offsets must contain one -360, 0, or 360 per overlay"
25
+ );
26
+ }
19
27
  const sources = {};
20
28
  const layers = [];
21
29
  for (const [index, overlay] of overlays.entries()) {
30
+ const longitudeOffset = options.longitudeOffsets?.[index] ?? 0;
22
31
  const id = safeLayerId(`overlay-${index + 1}-${overlay.id ?? overlay.type}`);
23
32
  const sourceId = `${id}-source`;
24
33
  if (overlay.type === "line") {
25
34
  sources[sourceId] = {
26
- data: feature("LineString", overlay.coordinates),
35
+ data: feature("LineString", shiftLongitudes(overlay.coordinates, longitudeOffset)),
27
36
  type: "geojson"
28
37
  };
29
38
  layers.push({
@@ -41,7 +50,7 @@ function compileStaticOverlays(overlays) {
41
50
  }
42
51
  if (overlay.type === "polygon") {
43
52
  sources[sourceId] = {
44
- data: feature("Polygon", overlay.coordinates),
53
+ data: feature("Polygon", shiftLongitudes(overlay.coordinates, longitudeOffset)),
45
54
  type: "geojson"
46
55
  };
47
56
  layers.push({
@@ -68,7 +77,7 @@ function compileStaticOverlays(overlays) {
68
77
  continue;
69
78
  }
70
79
  sources[sourceId] = {
71
- data: feature("Point", overlay.coordinate),
80
+ data: feature("Point", shiftLongitudes(overlay.coordinate, longitudeOffset)),
72
81
  type: "geojson"
73
82
  };
74
83
  layers.push({
@@ -86,6 +95,13 @@ function compileStaticOverlays(overlays) {
86
95
  }
87
96
  return { layers, sources };
88
97
  }
98
+ function shiftLongitudes(value, offset) {
99
+ if (!Array.isArray(value)) return value;
100
+ if (value.length === 2 && typeof value[0] === "number" && typeof value[1] === "number") {
101
+ return [roundNumber(value[0] + offset), value[1]];
102
+ }
103
+ return value.map((entry) => shiftLongitudes(entry, offset));
104
+ }
89
105
  function feature(type, coordinates) {
90
106
  return {
91
107
  geometry: { coordinates, type },