@tileflow/react 0.1.0-alpha.0
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 +13 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +486 -0
- package/dist/static.d.ts +27 -0
- package/dist/static.js +167 -0
- package/package.json +63 -0
package/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# @tileflow/react
|
|
2
|
+
|
|
3
|
+
React components for rendering Tileflow maps with MapLibre.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
import { Map } from "@tileflow/react";
|
|
7
|
+
|
|
8
|
+
export function App() {
|
|
9
|
+
return <Map map="madrid" center={[-3.7038, 40.4168]} zoom={12} />;
|
|
10
|
+
}
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Docs: https://tileflow.dev/docs
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { CSSProperties, ImgHTMLAttributes } from 'react';
|
|
3
|
+
import { TileflowConfig, MapLibreStyle } from '@tileflow/core';
|
|
4
|
+
import { Map as Map$1 } from 'maplibre-gl';
|
|
5
|
+
|
|
6
|
+
type MapMarker = {
|
|
7
|
+
id: string;
|
|
8
|
+
coordinates: [number, number];
|
|
9
|
+
color?: string;
|
|
10
|
+
label?: string;
|
|
11
|
+
};
|
|
12
|
+
type TileflowAnalytics = {
|
|
13
|
+
apiUrl?: string;
|
|
14
|
+
endpoint?: string;
|
|
15
|
+
enabled?: boolean;
|
|
16
|
+
mapId?: string;
|
|
17
|
+
metadata?: Record<string, unknown>;
|
|
18
|
+
projectId?: string;
|
|
19
|
+
sdkVersion?: string;
|
|
20
|
+
source?: string;
|
|
21
|
+
styleId?: string;
|
|
22
|
+
};
|
|
23
|
+
type MapProps = {
|
|
24
|
+
map?: string;
|
|
25
|
+
mode?: "interactive" | "image";
|
|
26
|
+
config?: TileflowConfig;
|
|
27
|
+
style?: MapLibreStyle;
|
|
28
|
+
styleUrl?: string;
|
|
29
|
+
imageUrl?: string;
|
|
30
|
+
styleBaseUrl?: string;
|
|
31
|
+
tileBaseUrl?: string;
|
|
32
|
+
manifestUrl?: string;
|
|
33
|
+
preferLocalDev?: boolean;
|
|
34
|
+
alt?: string;
|
|
35
|
+
center?: [number, number];
|
|
36
|
+
zoom?: number;
|
|
37
|
+
className?: string;
|
|
38
|
+
height?: CSSProperties["height"];
|
|
39
|
+
imageLoading?: ImgHTMLAttributes<HTMLImageElement>["loading"];
|
|
40
|
+
interactive?: boolean;
|
|
41
|
+
markers?: MapMarker[];
|
|
42
|
+
analytics?: TileflowAnalytics;
|
|
43
|
+
onLoad?: (map: Map$1) => void;
|
|
44
|
+
};
|
|
45
|
+
declare function Map({ map: mapName, mode, config, style, styleUrl, imageUrl, styleBaseUrl, tileBaseUrl, manifestUrl, preferLocalDev, alt, center, zoom, className, height, imageLoading, interactive, markers, analytics, onLoad, }: MapProps): react.JSX.Element;
|
|
46
|
+
|
|
47
|
+
export { Map, type MapMarker, type MapProps };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
// src/map.tsx
|
|
2
|
+
import {
|
|
3
|
+
createStyle
|
|
4
|
+
} from "@tileflow/core";
|
|
5
|
+
import maplibregl from "maplibre-gl";
|
|
6
|
+
import {
|
|
7
|
+
useEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState
|
|
11
|
+
} from "react";
|
|
12
|
+
import { jsx } from "react/jsx-runtime";
|
|
13
|
+
var defaultCenter = [0, 20];
|
|
14
|
+
var defaultMarkers = [];
|
|
15
|
+
var defaultManifestUrl = "/tileflow/manifest.json";
|
|
16
|
+
var maxStaticImageDimension = 1280;
|
|
17
|
+
var maxStaticImagePixels = 1280 * 1280;
|
|
18
|
+
var manifestCache = new globalThis.Map();
|
|
19
|
+
function Map({
|
|
20
|
+
map: mapName,
|
|
21
|
+
mode = "interactive",
|
|
22
|
+
config,
|
|
23
|
+
style,
|
|
24
|
+
styleUrl,
|
|
25
|
+
imageUrl,
|
|
26
|
+
styleBaseUrl,
|
|
27
|
+
tileBaseUrl,
|
|
28
|
+
manifestUrl = defaultManifestUrl,
|
|
29
|
+
preferLocalDev = true,
|
|
30
|
+
alt = "",
|
|
31
|
+
center = defaultCenter,
|
|
32
|
+
zoom = 2,
|
|
33
|
+
className,
|
|
34
|
+
height = 420,
|
|
35
|
+
imageLoading = "eager",
|
|
36
|
+
interactive = true,
|
|
37
|
+
markers = defaultMarkers,
|
|
38
|
+
analytics,
|
|
39
|
+
onLoad
|
|
40
|
+
}) {
|
|
41
|
+
const containerRef = useRef(null);
|
|
42
|
+
const mapRef = useRef(null);
|
|
43
|
+
const sessionId = useMemo(() => createSessionId(), []);
|
|
44
|
+
const [manifestMap, setManifestMap] = useState(
|
|
45
|
+
null
|
|
46
|
+
);
|
|
47
|
+
const [imageSize, setImageSize] = useState(null);
|
|
48
|
+
const isImageMode = mode === "image";
|
|
49
|
+
const useExplicitStyleBaseUrl = useMemo(
|
|
50
|
+
() => Boolean(
|
|
51
|
+
!isImageMode && mapName && !style && !styleUrl && !config && styleBaseUrl
|
|
52
|
+
),
|
|
53
|
+
[config, isImageMode, mapName, style, styleBaseUrl, styleUrl]
|
|
54
|
+
);
|
|
55
|
+
const useLocalDevFallback = useMemo(
|
|
56
|
+
() => Boolean(
|
|
57
|
+
!isImageMode && mapName && !style && !styleUrl && !config && !styleBaseUrl && preferLocalDev && isLocalDevHost()
|
|
58
|
+
),
|
|
59
|
+
[
|
|
60
|
+
config,
|
|
61
|
+
isImageMode,
|
|
62
|
+
mapName,
|
|
63
|
+
preferLocalDev,
|
|
64
|
+
style,
|
|
65
|
+
styleBaseUrl,
|
|
66
|
+
styleUrl
|
|
67
|
+
]
|
|
68
|
+
);
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const shouldLoadManifest = Boolean(
|
|
71
|
+
mapName && !config && (isImageMode && !imageUrl || !isImageMode && !style && !styleUrl && !useExplicitStyleBaseUrl)
|
|
72
|
+
);
|
|
73
|
+
if (!shouldLoadManifest) {
|
|
74
|
+
setManifestMap(null);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
let cancelled = false;
|
|
78
|
+
loadManifest(manifestUrl).then((manifest) => {
|
|
79
|
+
if (cancelled) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
setManifestMap(
|
|
83
|
+
manifest ? resolveManifestMap(manifest, mapName) : null
|
|
84
|
+
);
|
|
85
|
+
}).catch((error) => {
|
|
86
|
+
if (!cancelled) {
|
|
87
|
+
console.error("Failed to load Tileflow manifest", error);
|
|
88
|
+
setManifestMap(null);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
return () => {
|
|
92
|
+
cancelled = true;
|
|
93
|
+
};
|
|
94
|
+
}, [
|
|
95
|
+
config,
|
|
96
|
+
imageUrl,
|
|
97
|
+
isImageMode,
|
|
98
|
+
manifestUrl,
|
|
99
|
+
mapName,
|
|
100
|
+
style,
|
|
101
|
+
styleUrl,
|
|
102
|
+
useExplicitStyleBaseUrl
|
|
103
|
+
]);
|
|
104
|
+
const runtimeStyle = useMemo(() => {
|
|
105
|
+
if (isImageMode) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
if (style) {
|
|
109
|
+
return { style };
|
|
110
|
+
}
|
|
111
|
+
if (styleUrl) {
|
|
112
|
+
return {
|
|
113
|
+
analytics: inferAnalyticsFromStyleUrl(styleUrl),
|
|
114
|
+
style: styleUrl
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (mapName && useExplicitStyleBaseUrl) {
|
|
118
|
+
return { style: resolveStyleUrl(mapName, styleBaseUrl) };
|
|
119
|
+
}
|
|
120
|
+
if (mapName) {
|
|
121
|
+
if (manifestMap?.styleUrl) {
|
|
122
|
+
return {
|
|
123
|
+
analytics: {
|
|
124
|
+
apiUrl: manifestMap.apiUrl,
|
|
125
|
+
mapId: manifestMap.mapId,
|
|
126
|
+
styleId: manifestMap.styleId
|
|
127
|
+
},
|
|
128
|
+
style: manifestMap.styleUrl
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (useLocalDevFallback) {
|
|
132
|
+
return { style: resolveStyleUrl(mapName) };
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return { style: createStyle(config, { tileBaseUrl }) };
|
|
137
|
+
}, [
|
|
138
|
+
config,
|
|
139
|
+
isImageMode,
|
|
140
|
+
manifestMap,
|
|
141
|
+
mapName,
|
|
142
|
+
style,
|
|
143
|
+
styleBaseUrl,
|
|
144
|
+
styleUrl,
|
|
145
|
+
tileBaseUrl,
|
|
146
|
+
useExplicitStyleBaseUrl,
|
|
147
|
+
useLocalDevFallback
|
|
148
|
+
]);
|
|
149
|
+
const resolvedAnalytics = useMemo(
|
|
150
|
+
() => mergeAnalytics(analytics, runtimeStyle?.analytics),
|
|
151
|
+
[analytics, runtimeStyle]
|
|
152
|
+
);
|
|
153
|
+
const runtimeImageUrl = useMemo(
|
|
154
|
+
() => imageUrl ?? (isImageMode ? resolveStaticImageUrl({
|
|
155
|
+
center,
|
|
156
|
+
imageSize,
|
|
157
|
+
manifestMap,
|
|
158
|
+
zoom
|
|
159
|
+
}) : void 0),
|
|
160
|
+
[center, imageSize, imageUrl, isImageMode, manifestMap, zoom]
|
|
161
|
+
);
|
|
162
|
+
const transformRequest = useMemo(
|
|
163
|
+
() => createTransformRequest(resolvedAnalytics, sessionId),
|
|
164
|
+
[resolvedAnalytics, sessionId]
|
|
165
|
+
);
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (!isImageMode || imageUrl || !containerRef.current) {
|
|
168
|
+
setImageSize(null);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const element = containerRef.current;
|
|
172
|
+
const updateImageSize = () => {
|
|
173
|
+
const nextSize = normalizeStaticImageSize({
|
|
174
|
+
height: element.clientHeight,
|
|
175
|
+
width: element.clientWidth
|
|
176
|
+
});
|
|
177
|
+
setImageSize(
|
|
178
|
+
(currentSize) => currentSize?.height === nextSize.height && currentSize.width === nextSize.width ? currentSize : nextSize
|
|
179
|
+
);
|
|
180
|
+
};
|
|
181
|
+
const resizeObserver = new ResizeObserver(updateImageSize);
|
|
182
|
+
resizeObserver.observe(element);
|
|
183
|
+
updateImageSize();
|
|
184
|
+
return () => {
|
|
185
|
+
resizeObserver.disconnect();
|
|
186
|
+
};
|
|
187
|
+
}, [imageUrl, isImageMode]);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (!containerRef.current || !runtimeStyle) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const map = new maplibregl.Map({
|
|
193
|
+
container: containerRef.current,
|
|
194
|
+
style: runtimeStyle.style,
|
|
195
|
+
center,
|
|
196
|
+
zoom,
|
|
197
|
+
interactive,
|
|
198
|
+
attributionControl: { compact: true },
|
|
199
|
+
transformRequest
|
|
200
|
+
});
|
|
201
|
+
mapRef.current = map;
|
|
202
|
+
const controls = interactive ? new maplibregl.NavigationControl() : null;
|
|
203
|
+
if (controls) {
|
|
204
|
+
map.addControl(controls, "top-right");
|
|
205
|
+
}
|
|
206
|
+
const markerInstances = markers.map((marker) => {
|
|
207
|
+
const element = document.createElement("div");
|
|
208
|
+
element.className = "tileflow-marker";
|
|
209
|
+
element.style.background = marker.color ?? "#C6A15B";
|
|
210
|
+
element.title = marker.label ?? marker.id;
|
|
211
|
+
return new maplibregl.Marker({ element }).setLngLat(marker.coordinates).addTo(map);
|
|
212
|
+
});
|
|
213
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
214
|
+
map.resize();
|
|
215
|
+
});
|
|
216
|
+
resizeObserver.observe(containerRef.current);
|
|
217
|
+
map.on("load", () => {
|
|
218
|
+
onLoad?.(map);
|
|
219
|
+
startSession(resolvedAnalytics, {
|
|
220
|
+
mapId: resolvedAnalytics?.mapId,
|
|
221
|
+
sessionId,
|
|
222
|
+
styleId: resolvedAnalytics?.styleId ?? (typeof runtimeStyle.style === "string" ? runtimeStyle.style : mapName)
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
return () => {
|
|
226
|
+
resizeObserver.disconnect();
|
|
227
|
+
markerInstances.forEach((marker) => marker.remove());
|
|
228
|
+
map.remove();
|
|
229
|
+
mapRef.current = null;
|
|
230
|
+
};
|
|
231
|
+
}, [
|
|
232
|
+
center,
|
|
233
|
+
interactive,
|
|
234
|
+
mapName,
|
|
235
|
+
markers,
|
|
236
|
+
onLoad,
|
|
237
|
+
resolvedAnalytics,
|
|
238
|
+
runtimeStyle,
|
|
239
|
+
sessionId,
|
|
240
|
+
transformRequest,
|
|
241
|
+
zoom
|
|
242
|
+
]);
|
|
243
|
+
const frameStyle = {
|
|
244
|
+
height,
|
|
245
|
+
minHeight: 240,
|
|
246
|
+
overflow: "hidden",
|
|
247
|
+
position: "relative",
|
|
248
|
+
width: "100%"
|
|
249
|
+
};
|
|
250
|
+
if (isImageMode) {
|
|
251
|
+
return /* @__PURE__ */ jsx("div", { className, ref: containerRef, style: frameStyle, children: runtimeImageUrl ? /* @__PURE__ */ jsx(
|
|
252
|
+
"img",
|
|
253
|
+
{
|
|
254
|
+
alt,
|
|
255
|
+
decoding: "async",
|
|
256
|
+
loading: imageLoading,
|
|
257
|
+
src: runtimeImageUrl,
|
|
258
|
+
style: {
|
|
259
|
+
display: "block",
|
|
260
|
+
height: "100%",
|
|
261
|
+
objectFit: "cover",
|
|
262
|
+
width: "100%"
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
) : null });
|
|
266
|
+
}
|
|
267
|
+
return /* @__PURE__ */ jsx(
|
|
268
|
+
"div",
|
|
269
|
+
{
|
|
270
|
+
className,
|
|
271
|
+
ref: containerRef,
|
|
272
|
+
style: frameStyle
|
|
273
|
+
}
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
function resolveStyleUrl(mapName, styleBaseUrl) {
|
|
277
|
+
const baseUrl = normalizeUrl(styleBaseUrl ?? getDefaultStyleBaseUrl());
|
|
278
|
+
return `${baseUrl}/styles/${mapName}.json`;
|
|
279
|
+
}
|
|
280
|
+
function getDefaultStyleBaseUrl() {
|
|
281
|
+
const globalValue = globalThis.__TILEFLOW_STYLE_BASE_URL__;
|
|
282
|
+
return globalValue ?? "http://localhost:3333";
|
|
283
|
+
}
|
|
284
|
+
function normalizeUrl(value) {
|
|
285
|
+
return value.replace(/\/+$/, "");
|
|
286
|
+
}
|
|
287
|
+
function createSessionId() {
|
|
288
|
+
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
|
289
|
+
return `ses_${crypto.randomUUID()}`;
|
|
290
|
+
}
|
|
291
|
+
return `ses_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
|
|
292
|
+
}
|
|
293
|
+
function createTransformRequest(analytics, sessionId) {
|
|
294
|
+
if (!analytics || analytics.enabled === false) {
|
|
295
|
+
return void 0;
|
|
296
|
+
}
|
|
297
|
+
return (url) => {
|
|
298
|
+
const apiUrl = getAnalyticsApiUrl(analytics);
|
|
299
|
+
if (!apiUrl || !isTileflowTileUrl(url, apiUrl)) {
|
|
300
|
+
return void 0;
|
|
301
|
+
}
|
|
302
|
+
const nextUrl = new URL(url);
|
|
303
|
+
nextUrl.searchParams.set("session", sessionId);
|
|
304
|
+
if (analytics.mapId) {
|
|
305
|
+
nextUrl.searchParams.set("map", analytics.mapId);
|
|
306
|
+
}
|
|
307
|
+
if (analytics.styleId) {
|
|
308
|
+
nextUrl.searchParams.set("styleId", analytics.styleId);
|
|
309
|
+
}
|
|
310
|
+
return { url: nextUrl.toString() };
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function startSession(analytics, input) {
|
|
314
|
+
if (!analytics || analytics.enabled === false || !input.mapId || typeof navigator === "undefined") {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const endpoint = analytics.endpoint ?? `${getAnalyticsApiUrl(analytics)}/v1/sessions/start`;
|
|
318
|
+
const payload = JSON.stringify({
|
|
319
|
+
mapId: input.mapId,
|
|
320
|
+
metadata: analytics.metadata,
|
|
321
|
+
referrer: typeof document === "undefined" ? void 0 : document.referrer || "",
|
|
322
|
+
sdkVersion: analytics.sdkVersion,
|
|
323
|
+
sessionId: input.sessionId,
|
|
324
|
+
source: analytics.source ?? "react",
|
|
325
|
+
styleId: input.styleId,
|
|
326
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
327
|
+
});
|
|
328
|
+
const blob = new Blob([payload], { type: "application/json" });
|
|
329
|
+
if (navigator.sendBeacon?.(endpoint, blob)) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
void fetch(endpoint, {
|
|
333
|
+
body: payload,
|
|
334
|
+
credentials: "omit",
|
|
335
|
+
headers: {
|
|
336
|
+
"Content-Type": "application/json"
|
|
337
|
+
},
|
|
338
|
+
keepalive: true,
|
|
339
|
+
method: "POST"
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
function getAnalyticsApiUrl(analytics) {
|
|
343
|
+
if (analytics.apiUrl) {
|
|
344
|
+
return normalizeUrl(analytics.apiUrl);
|
|
345
|
+
}
|
|
346
|
+
if (analytics.endpoint) {
|
|
347
|
+
const url = new URL(analytics.endpoint);
|
|
348
|
+
return url.origin;
|
|
349
|
+
}
|
|
350
|
+
return "https://api.tileflow.dev";
|
|
351
|
+
}
|
|
352
|
+
function mergeAnalytics(userAnalytics, runtimeAnalytics) {
|
|
353
|
+
if (userAnalytics?.enabled === false) {
|
|
354
|
+
return userAnalytics;
|
|
355
|
+
}
|
|
356
|
+
if (!userAnalytics && !runtimeAnalytics?.mapId) {
|
|
357
|
+
return void 0;
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
...runtimeAnalytics,
|
|
361
|
+
...userAnalytics,
|
|
362
|
+
apiUrl: userAnalytics?.apiUrl ?? runtimeAnalytics?.apiUrl,
|
|
363
|
+
mapId: userAnalytics?.mapId ?? runtimeAnalytics?.mapId,
|
|
364
|
+
styleId: userAnalytics?.styleId ?? runtimeAnalytics?.styleId
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
function isTileflowTileUrl(url, apiUrl) {
|
|
368
|
+
try {
|
|
369
|
+
const parsedUrl = new URL(url);
|
|
370
|
+
const parsedApiUrl = new URL(apiUrl);
|
|
371
|
+
return parsedUrl.origin === parsedApiUrl.origin && isTilePath(parsedUrl);
|
|
372
|
+
} catch {
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function isTilePath(url) {
|
|
377
|
+
return url.pathname.startsWith("/tiles/") || url.pathname.startsWith("/v1/tiles/");
|
|
378
|
+
}
|
|
379
|
+
function isLocalDevHost() {
|
|
380
|
+
if (typeof window === "undefined") {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
return ["localhost", "127.0.0.1", "::1"].includes(window.location.hostname);
|
|
384
|
+
}
|
|
385
|
+
function loadManifest(url) {
|
|
386
|
+
if (!manifestCache.has(url)) {
|
|
387
|
+
manifestCache.set(
|
|
388
|
+
url,
|
|
389
|
+
fetch(url, { cache: "no-store" }).then(async (response) => {
|
|
390
|
+
if (response.status === 404) {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
if (!response.ok) {
|
|
394
|
+
throw new Error(`Tileflow manifest failed: ${response.status}`);
|
|
395
|
+
}
|
|
396
|
+
return await response.json();
|
|
397
|
+
})
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
return manifestCache.get(url);
|
|
401
|
+
}
|
|
402
|
+
function resolveManifestMap(manifest, mapName) {
|
|
403
|
+
const entry = manifest.maps?.[mapName] ?? manifest.styles?.[mapName];
|
|
404
|
+
if (!entry) {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
if (typeof entry === "string") {
|
|
408
|
+
const analytics2 = inferAnalyticsFromStyleUrl(entry);
|
|
409
|
+
return {
|
|
410
|
+
apiUrl: analytics2?.apiUrl ?? manifest.apiUrl,
|
|
411
|
+
mapId: analytics2?.mapId,
|
|
412
|
+
styleId: analytics2?.styleId,
|
|
413
|
+
styleUrl: entry
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
const styleUrl = entry.styleUrl ?? entry.url;
|
|
417
|
+
if (!styleUrl && !entry.mapId) {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
const analytics = styleUrl ? inferAnalyticsFromStyleUrl(styleUrl) : void 0;
|
|
421
|
+
return {
|
|
422
|
+
apiUrl: entry.apiUrl ?? manifest.apiUrl ?? analytics?.apiUrl,
|
|
423
|
+
mapId: entry.mapId ?? analytics?.mapId,
|
|
424
|
+
styleId: entry.styleId ?? analytics?.styleId,
|
|
425
|
+
styleUrl
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
function resolveStaticImageUrl(input) {
|
|
429
|
+
if (!input.manifestMap?.mapId || !input.imageSize) {
|
|
430
|
+
return void 0;
|
|
431
|
+
}
|
|
432
|
+
const apiUrl = normalizeUrl(
|
|
433
|
+
input.manifestMap.apiUrl ?? "https://api.tileflow.dev"
|
|
434
|
+
);
|
|
435
|
+
const url = new URL(`/maps/${input.manifestMap.mapId}/static.png`, apiUrl);
|
|
436
|
+
url.searchParams.set("center", input.center.join(","));
|
|
437
|
+
url.searchParams.set("zoom", String(input.zoom));
|
|
438
|
+
url.searchParams.set("width", String(input.imageSize.width));
|
|
439
|
+
url.searchParams.set("height", String(input.imageSize.height));
|
|
440
|
+
return url.toString();
|
|
441
|
+
}
|
|
442
|
+
function normalizeStaticImageSize(input) {
|
|
443
|
+
let width = clampDimension(input.width);
|
|
444
|
+
let height = clampDimension(input.height);
|
|
445
|
+
const pixelCount = width * height;
|
|
446
|
+
if (pixelCount > maxStaticImagePixels) {
|
|
447
|
+
const scale = Math.sqrt(maxStaticImagePixels / pixelCount);
|
|
448
|
+
width = clampDimension(width * scale);
|
|
449
|
+
height = clampDimension(height * scale);
|
|
450
|
+
}
|
|
451
|
+
return { height, width };
|
|
452
|
+
}
|
|
453
|
+
function clampDimension(value) {
|
|
454
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
455
|
+
return 640;
|
|
456
|
+
}
|
|
457
|
+
return Math.max(
|
|
458
|
+
64,
|
|
459
|
+
Math.min(maxStaticImageDimension, Math.round(value))
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
function inferAnalyticsFromStyleUrl(styleUrl) {
|
|
463
|
+
try {
|
|
464
|
+
const url = new URL(styleUrl, getLocationOrigin());
|
|
465
|
+
const match = url.pathname.match(/\/(?:v1\/)?maps\/([^/]+)\/style\.json$/);
|
|
466
|
+
if (!match?.[1]) {
|
|
467
|
+
return void 0;
|
|
468
|
+
}
|
|
469
|
+
return {
|
|
470
|
+
apiUrl: url.origin,
|
|
471
|
+
mapId: match[1],
|
|
472
|
+
styleId: url.toString()
|
|
473
|
+
};
|
|
474
|
+
} catch {
|
|
475
|
+
return void 0;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
function getLocationOrigin() {
|
|
479
|
+
if (typeof window === "undefined") {
|
|
480
|
+
return "http://localhost";
|
|
481
|
+
}
|
|
482
|
+
return window.location.origin;
|
|
483
|
+
}
|
|
484
|
+
export {
|
|
485
|
+
Map
|
|
486
|
+
};
|
package/dist/static.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { CSSProperties, ImgHTMLAttributes } from 'react';
|
|
3
|
+
import { StaticSceneInput, StaticMapResult } from '@tileflow/static';
|
|
4
|
+
|
|
5
|
+
type StaticMapBaseProps = StaticSceneInput & {
|
|
6
|
+
alt?: string;
|
|
7
|
+
className?: string;
|
|
8
|
+
imageStyle?: CSSProperties;
|
|
9
|
+
keepPreviousImage?: boolean;
|
|
10
|
+
loading?: ImgHTMLAttributes<HTMLImageElement>["loading"];
|
|
11
|
+
onError?: (error: Error) => void;
|
|
12
|
+
onImageLoad?: ImgHTMLAttributes<HTMLImageElement>["onLoad"];
|
|
13
|
+
onReady?: (result: StaticMapResult) => void;
|
|
14
|
+
style?: CSSProperties;
|
|
15
|
+
};
|
|
16
|
+
type StaticMapImageProps = {
|
|
17
|
+
createUrl?: never;
|
|
18
|
+
imageUrl: string;
|
|
19
|
+
};
|
|
20
|
+
type StaticMapCreateProps = {
|
|
21
|
+
createUrl: string;
|
|
22
|
+
imageUrl?: never;
|
|
23
|
+
};
|
|
24
|
+
type StaticMapProps = StaticMapBaseProps & (StaticMapCreateProps | StaticMapImageProps);
|
|
25
|
+
declare function StaticMap({ alt, camera, className, createUrl, imageStyle, imageUrl, keepPreviousImage, loading, map, onError, onImageLoad, onReady, overlays, size, style, }: StaticMapProps): react.JSX.Element;
|
|
26
|
+
|
|
27
|
+
export { StaticMap, type StaticMapProps };
|
package/dist/static.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// src/static-map.tsx
|
|
2
|
+
import {
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useState
|
|
7
|
+
} from "react";
|
|
8
|
+
import { jsx } from "react/jsx-runtime";
|
|
9
|
+
var inFlightRequests = /* @__PURE__ */ new Map();
|
|
10
|
+
function StaticMap({
|
|
11
|
+
alt = "",
|
|
12
|
+
camera,
|
|
13
|
+
className,
|
|
14
|
+
createUrl,
|
|
15
|
+
imageStyle,
|
|
16
|
+
imageUrl,
|
|
17
|
+
keepPreviousImage = false,
|
|
18
|
+
loading = "lazy",
|
|
19
|
+
map,
|
|
20
|
+
onError,
|
|
21
|
+
onImageLoad,
|
|
22
|
+
onReady,
|
|
23
|
+
overlays,
|
|
24
|
+
size,
|
|
25
|
+
style
|
|
26
|
+
}) {
|
|
27
|
+
const [result, setResult] = useState(
|
|
28
|
+
imageUrl ? imageResult(imageUrl) : null
|
|
29
|
+
);
|
|
30
|
+
const [error, setError] = useState(null);
|
|
31
|
+
const onErrorRef = useRef(onError);
|
|
32
|
+
const onReadyRef = useRef(onReady);
|
|
33
|
+
const sceneKey = useMemo(
|
|
34
|
+
() => stableStringify({ camera, map, overlays, size }),
|
|
35
|
+
[camera, map, overlays, size]
|
|
36
|
+
);
|
|
37
|
+
const requestKey = imageUrl ? `image:${imageUrl}` : `create:${createUrl}:${sceneKey}`;
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
onErrorRef.current = onError;
|
|
40
|
+
onReadyRef.current = onReady;
|
|
41
|
+
}, [onError, onReady]);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (imageUrl) {
|
|
44
|
+
const nextResult = imageResult(imageUrl);
|
|
45
|
+
setResult(nextResult);
|
|
46
|
+
setError(null);
|
|
47
|
+
onReadyRef.current?.(nextResult);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
let cancelled = false;
|
|
51
|
+
if (!createUrl) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!keepPreviousImage) {
|
|
55
|
+
setResult(null);
|
|
56
|
+
}
|
|
57
|
+
setError(null);
|
|
58
|
+
resolveStaticMap({
|
|
59
|
+
createUrl,
|
|
60
|
+
requestKey,
|
|
61
|
+
scene: JSON.parse(sceneKey)
|
|
62
|
+
}).then((nextResult) => {
|
|
63
|
+
if (cancelled) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
setResult(nextResult);
|
|
67
|
+
setError(null);
|
|
68
|
+
onReadyRef.current?.(nextResult);
|
|
69
|
+
}).catch((nextError) => {
|
|
70
|
+
if (cancelled) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const normalizedError = nextError instanceof Error ? nextError : new Error("Static map failed");
|
|
74
|
+
if (!keepPreviousImage) {
|
|
75
|
+
setResult(null);
|
|
76
|
+
}
|
|
77
|
+
setError(normalizedError);
|
|
78
|
+
onErrorRef.current?.(normalizedError);
|
|
79
|
+
});
|
|
80
|
+
return () => {
|
|
81
|
+
cancelled = true;
|
|
82
|
+
};
|
|
83
|
+
}, [createUrl, imageUrl, keepPreviousImage, requestKey, sceneKey]);
|
|
84
|
+
return /* @__PURE__ */ jsx(
|
|
85
|
+
"div",
|
|
86
|
+
{
|
|
87
|
+
"aria-busy": !result && !error,
|
|
88
|
+
className,
|
|
89
|
+
style: {
|
|
90
|
+
aspectRatio: `${size.width} / ${size.height}`,
|
|
91
|
+
background: "#f4f4f2",
|
|
92
|
+
overflow: "hidden",
|
|
93
|
+
width: "100%",
|
|
94
|
+
...style
|
|
95
|
+
},
|
|
96
|
+
children: result ? /* @__PURE__ */ jsx(
|
|
97
|
+
"img",
|
|
98
|
+
{
|
|
99
|
+
alt,
|
|
100
|
+
decoding: "async",
|
|
101
|
+
loading,
|
|
102
|
+
onError: () => {
|
|
103
|
+
const imageError = new Error("Static map image failed to load");
|
|
104
|
+
setError(imageError);
|
|
105
|
+
onErrorRef.current?.(imageError);
|
|
106
|
+
},
|
|
107
|
+
onLoad: onImageLoad,
|
|
108
|
+
src: result.imageUrl,
|
|
109
|
+
style: {
|
|
110
|
+
display: "block",
|
|
111
|
+
height: "100%",
|
|
112
|
+
objectFit: "cover",
|
|
113
|
+
width: "100%",
|
|
114
|
+
...imageStyle
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
) : null
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
async function resolveStaticMap(input) {
|
|
122
|
+
const existing = inFlightRequests.get(input.requestKey);
|
|
123
|
+
if (existing) {
|
|
124
|
+
return existing;
|
|
125
|
+
}
|
|
126
|
+
const promise = fetch(input.createUrl, {
|
|
127
|
+
body: JSON.stringify(input.scene),
|
|
128
|
+
headers: {
|
|
129
|
+
"Content-Type": "application/json"
|
|
130
|
+
},
|
|
131
|
+
method: "POST"
|
|
132
|
+
}).then(async (response) => {
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
throw new Error(`Static map endpoint failed: ${response.status}`);
|
|
135
|
+
}
|
|
136
|
+
return await response.json();
|
|
137
|
+
}).finally(() => {
|
|
138
|
+
inFlightRequests.delete(input.requestKey);
|
|
139
|
+
});
|
|
140
|
+
inFlightRequests.set(input.requestKey, promise);
|
|
141
|
+
return promise;
|
|
142
|
+
}
|
|
143
|
+
function imageResult(imageUrl) {
|
|
144
|
+
return {
|
|
145
|
+
cached: true,
|
|
146
|
+
hash: "",
|
|
147
|
+
imageUrl,
|
|
148
|
+
status: "ready"
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function stableStringify(value) {
|
|
152
|
+
return JSON.stringify(sortJson(value));
|
|
153
|
+
}
|
|
154
|
+
function sortJson(value) {
|
|
155
|
+
if (Array.isArray(value)) {
|
|
156
|
+
return value.map(sortJson);
|
|
157
|
+
}
|
|
158
|
+
if (value && typeof value === "object") {
|
|
159
|
+
return Object.fromEntries(
|
|
160
|
+
Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, sortJson(entry)])
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
export {
|
|
166
|
+
StaticMap
|
|
167
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tileflow/react",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "React components for rendering Tileflow maps with MapLibre.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"homepage": "https://tileflow.dev",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/tileflow/tileflow.git",
|
|
10
|
+
"directory": "packages/react"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/tileflow/tileflow/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"tileflow",
|
|
17
|
+
"react",
|
|
18
|
+
"maps",
|
|
19
|
+
"maplibre",
|
|
20
|
+
"tiles"
|
|
21
|
+
],
|
|
22
|
+
"main": "./dist/index.js",
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"import": "./dist/index.js",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./static": {
|
|
34
|
+
"types": "./dist/static.d.ts",
|
|
35
|
+
"import": "./dist/static.js",
|
|
36
|
+
"default": "./dist/static.js"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@tileflow/core": "0.1.0-alpha.0",
|
|
44
|
+
"@tileflow/static": "0.1.0-alpha.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"maplibre-gl": ">=5",
|
|
48
|
+
"react": ">=18",
|
|
49
|
+
"react-dom": ">=18"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"maplibre-gl": "^5.24.0",
|
|
53
|
+
"react": "^19.2.7",
|
|
54
|
+
"react-dom": "^19.2.7",
|
|
55
|
+
"@types/react": "^19.2.17",
|
|
56
|
+
"@types/react-dom": "^19.2.3"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "tsup src/index.ts src/static.ts --format esm --dts --clean --external react --external react-dom --external maplibre-gl",
|
|
60
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
61
|
+
"lint": "tsc -p tsconfig.json --noEmit"
|
|
62
|
+
}
|
|
63
|
+
}
|