@gustkit/mapbox-web 0.2.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.
@@ -0,0 +1,540 @@
1
+ import { useEffect, useRef, useState, type ReactNode } from "react";
2
+ import mapboxgl from "mapbox-gl";
3
+ import { CanvasParticleField, type CanvasProjectionAdapter } from "@gustkit/client/particles/canvas";
4
+ import {
5
+ Product,
6
+ hasColorField,
7
+ syncFieldSession,
8
+ vectorAt,
9
+ type FieldSessionRequest,
10
+ type GeoBounds,
11
+ type GustKitSource,
12
+ } from "@gustkit/client";
13
+ import { fieldToDataUrl } from "./fieldPng.js";
14
+ import { paintArrowGrid, paintArrowSpots, sparseArrowSpots } from "./arrows.js";
15
+ import { ensureMapStyles } from "./styles.js";
16
+
17
+ const TRANSPARENT_PX =
18
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
19
+ const EMPTY_FC: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: [] };
20
+ const DEFAULT_STYLE = "mapbox://styles/mapbox/dark-v11";
21
+
22
+ /**
23
+ * Basemap tints, 20% darker than the stock dark-v11 values.
24
+ *
25
+ * Counter-intuitive on a dark style, but we lighten LAND to #5b6474 so it reads
26
+ * under a translucent weather overlay — and dark-v11's borders sit at
27
+ * hsl(0 0% 38-41%), which is almost exactly that luminance, so they vanish into
28
+ * the landmass. Taking them down 20% restores the contrast the stock style gets
29
+ * from near-black land.
30
+ */
31
+ const LAND_FILL = "#5b6474";
32
+ const COASTLINE = "#434446"; // was #545558
33
+ const ADMIN_0 = "hsl(0, 0%, 33%)"; // country borders, was 41%
34
+ const ADMIN_1 = "hsl(0, 0%, 30%)"; // state/province borders, was 38%
35
+
36
+ export interface GustKitMapLayers {
37
+ field?: boolean;
38
+ particles?: boolean;
39
+ isobars?: boolean;
40
+ arrows?: boolean;
41
+ centers?: boolean;
42
+ }
43
+
44
+ export interface GustKitMapHover {
45
+ lon: number;
46
+ lat: number;
47
+ x: number;
48
+ y: number;
49
+ }
50
+
51
+ /**
52
+ * Neighbour prefetch is a scroll-smoothness luxury: each extra valid time is a
53
+ * FULL extra field (12 packs / ~540KB at a basin-wide view). On a metered or slow
54
+ * link that competes with the first paint for the same pipe, so skip it there and
55
+ * let the visible step have the bandwidth to itself. Unknown link ⇒ prefetch, so
56
+ * desktop behaviour is unchanged.
57
+ */
58
+ function prefetchIsWorthIt(): boolean {
59
+ const c = (navigator as { connection?: { saveData?: boolean; effectiveType?: string } }).connection;
60
+ if (!c) return true;
61
+ if (c.saveData) return false;
62
+ return !(c.effectiveType === "slow-2g" || c.effectiveType === "2g" || c.effectiveType === "3g");
63
+ }
64
+
65
+ export interface GustKitMapProps {
66
+ accessToken: string;
67
+ source: GustKitSource;
68
+ product: Product;
69
+ timeMs: number;
70
+ validTimes?: number[];
71
+ layers?: GustKitMapLayers;
72
+ opacity?: number;
73
+ center?: [number, number];
74
+ zoom?: number;
75
+ interactive?: boolean;
76
+ /** Field pad fraction. Live maps 0.5; locked postcards 0. */
77
+ pad?: number;
78
+ marginTiles?: number;
79
+ particleCount?: number;
80
+ particleColor?: string;
81
+ downsample?: number;
82
+ /** "grid" = live map; "sparse" = postcard (≤6 arrows). */
83
+ arrowMode?: "grid" | "sparse";
84
+ styleURL?: string;
85
+ className?: string;
86
+ children?: ReactNode;
87
+ /** Native Mapbox instance after style load — add boats, extra layers. */
88
+ onMap?: (map: mapboxgl.Map) => void;
89
+ onHover?: (info: GustKitMapHover | null) => void;
90
+ /**
91
+ * Press-and-hold on a point (mouse or touch). Fires once per gesture, and only
92
+ * if the pointer stayed put — a drag/pan is a map gesture, not a long press.
93
+ */
94
+ onLongPress?: (info: GustKitMapHover) => void;
95
+ /** Long-press duration in ms (default 450). */
96
+ longPressMs?: number;
97
+ onFieldReady?: (rasterMs: number, pixels: number) => void;
98
+ }
99
+
100
+ function mapBounds(map: mapboxgl.Map): GeoBounds {
101
+ const b = map.getBounds()!;
102
+ return { west: b.getWest(), south: b.getSouth(), east: b.getEast(), north: b.getNorth() };
103
+ }
104
+
105
+ function quadOf(b: GeoBounds): [[number, number], [number, number], [number, number], [number, number]] {
106
+ return [
107
+ [b.west, b.north],
108
+ [b.east, b.north],
109
+ [b.east, b.south],
110
+ [b.west, b.south],
111
+ ];
112
+ }
113
+
114
+ function canvasAdapter(map: mapboxgl.Map): CanvasProjectionAdapter {
115
+ return {
116
+ getBounds: () => mapBounds(map),
117
+ getSize: () => {
118
+ const el = map.getContainer();
119
+ return { width: el.clientWidth, height: el.clientHeight };
120
+ },
121
+ project: (lon, lat) => {
122
+ const p = map.project([lon, lat]);
123
+ return { x: p.x, y: p.y };
124
+ },
125
+ pxPerDeg: () => {
126
+ const c = map.getCenter();
127
+ const a = map.project([c.lng, c.lat]);
128
+ const d = map.project([c.lng + 1, c.lat]);
129
+ return Math.hypot(d.x - a.x, d.y - a.y) || 1;
130
+ },
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Default Mapbox weather map (ADR-016). Owns the Mapbox instance, the weather
136
+ * stack (field under labels, coastline, particles, isobars, arrows, centers),
137
+ * and `loadView` on camera idle via {@link syncFieldSession}.
138
+ */
139
+ export function GustKitMap({
140
+ accessToken,
141
+ source,
142
+ product,
143
+ timeMs,
144
+ validTimes,
145
+ layers = {},
146
+ opacity = 0.6,
147
+ center = [-55, 32],
148
+ zoom = 3,
149
+ interactive = true,
150
+ pad,
151
+ marginTiles,
152
+ particleCount = 1800,
153
+ particleColor = "rgba(255,255,255,0.7)",
154
+ downsample = 3,
155
+ arrowMode = "grid",
156
+ styleURL = DEFAULT_STYLE,
157
+ className,
158
+ children,
159
+ onMap,
160
+ onHover,
161
+ onLongPress,
162
+ longPressMs = 450,
163
+ onFieldReady,
164
+ }: GustKitMapProps) {
165
+ const containerRef = useRef<HTMLDivElement>(null);
166
+ const mapRef = useRef<mapboxgl.Map | null>(null);
167
+ const fieldRef = useRef<CanvasParticleField | null>(null);
168
+ const arrowsCanvasRef = useRef<HTMLCanvasElement | null>(null);
169
+ const markersRef = useRef<mapboxgl.Marker[]>([]);
170
+ const [ready, setReady] = useState(false);
171
+ const propsRef = useRef({
172
+ source,
173
+ product,
174
+ timeMs,
175
+ validTimes,
176
+ layers,
177
+ opacity,
178
+ pad,
179
+ marginTiles,
180
+ downsample,
181
+ arrowMode,
182
+ particleCount,
183
+ onFieldReady,
184
+ onHover,
185
+ onLongPress,
186
+ });
187
+ propsRef.current = {
188
+ source,
189
+ product,
190
+ timeMs,
191
+ validTimes,
192
+ layers,
193
+ opacity,
194
+ pad,
195
+ marginTiles,
196
+ downsample,
197
+ arrowMode,
198
+ particleCount,
199
+ onFieldReady,
200
+ onHover,
201
+ onLongPress,
202
+ };
203
+
204
+ useEffect(() => {
205
+ ensureMapStyles();
206
+ }, []);
207
+
208
+ useEffect(() => {
209
+ if (!containerRef.current || !accessToken) return;
210
+ mapboxgl.accessToken = accessToken;
211
+ const map = new mapboxgl.Map({
212
+ container: containerRef.current,
213
+ style: styleURL,
214
+ center,
215
+ zoom,
216
+ interactive,
217
+ projection: "mercator",
218
+ attributionControl: false,
219
+ fadeDuration: interactive ? undefined : 0,
220
+ });
221
+ mapRef.current = map;
222
+
223
+ map.on("load", () => {
224
+ // Every one of these is best-effort: a caller can pass their own
225
+ // `styleURL`, and these ids are dark-v11's. A style without them keeps its
226
+ // own colours rather than throwing.
227
+ for (const [layer, prop, value] of [
228
+ ["land", "background-color", LAND_FILL],
229
+ ["admin-0-boundary", "line-color", ADMIN_0],
230
+ ["admin-1-boundary", "line-color", ADMIN_1],
231
+ ] as const) {
232
+ try {
233
+ map.setPaintProperty(layer, prop, value);
234
+ } catch {
235
+ /* style layer id differs */
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Insert our overlays BELOW the whole admin group, not just below
241
+ * `admin-0-boundary`.
242
+ *
243
+ * dark-v11 orders admin layers as [admin-1-bg, admin-0-bg, admin-1,
244
+ * admin-0, admin-0-disputed], so anchoring to `admin-0-boundary` dropped
245
+ * the weather raster on top of `admin-1-boundary` — country borders
246
+ * survived and every state/province line was painted over. Anchoring to
247
+ * the FIRST admin layer keeps them all above the weather.
248
+ */
249
+ const adminAnchor = map.getStyle().layers?.find((l) => l.id.startsWith("admin"))?.id;
250
+ const b0 = mapBounds(map);
251
+ map.addSource("gk-field", { type: "image", url: TRANSPARENT_PX, coordinates: quadOf(b0) });
252
+ map.addLayer(
253
+ {
254
+ id: "gk-field",
255
+ type: "raster",
256
+ source: "gk-field",
257
+ paint: { "raster-opacity": propsRef.current.opacity, "raster-resampling": "linear", "raster-fade-duration": 0 },
258
+ },
259
+ adminAnchor,
260
+ );
261
+ map.addLayer(
262
+ {
263
+ id: "gk-coastline",
264
+ type: "line",
265
+ source: "composite",
266
+ "source-layer": "water",
267
+ layout: { "line-join": "round" },
268
+ paint: { "line-color": COASTLINE, "line-width": 1.4, "line-opacity": 0.9 },
269
+ },
270
+ adminAnchor,
271
+ );
272
+ map.addSource("gk-isobars", { type: "geojson", data: EMPTY_FC });
273
+ map.addLayer({
274
+ id: "gk-isobars",
275
+ type: "line",
276
+ source: "gk-isobars",
277
+ layout: { "line-join": "round", "line-cap": "round" },
278
+ paint: {
279
+ "line-color": ["case", ["get", "major"], "#ffffff", "#c3d4e8"],
280
+ "line-width": ["case", ["get", "major"], 1.3, 0.6],
281
+ "line-opacity": 0.7,
282
+ },
283
+ });
284
+
285
+ const cc = map.getCanvasContainer();
286
+ const arrows = document.createElement("canvas");
287
+ arrows.className = "gk-overlay gk-arrows";
288
+ cc.appendChild(arrows);
289
+ arrowsCanvasRef.current = arrows;
290
+
291
+ const particles = document.createElement("canvas");
292
+ particles.className = "gk-overlay gk-particles";
293
+ cc.appendChild(particles);
294
+ const field = new CanvasParticleField(particles, canvasAdapter(map), {
295
+ sample: source.vectorSampler(product) ?? (() => null),
296
+ color: particleColor,
297
+ count: particleCount,
298
+ });
299
+ fieldRef.current = field;
300
+ field.start();
301
+
302
+ map.on("movestart", () => {
303
+ field.moveStart();
304
+ clearArrows();
305
+ });
306
+ map.on("moveend", () => {
307
+ field.moveEnd();
308
+ refreshField();
309
+ drawArrows();
310
+ });
311
+ map.on("resize", () => {
312
+ field.resize();
313
+ refreshField();
314
+ drawArrows();
315
+ });
316
+ map.on("mousemove", (e) => {
317
+ propsRef.current.onHover?.({ lon: e.lngLat.lng, lat: e.lngLat.lat, x: e.point.x, y: e.point.y });
318
+ });
319
+ map.on("mouseout", () => propsRef.current.onHover?.(null));
320
+
321
+ // Press-and-hold → point query. Cancelled by movement beyond a slop radius
322
+ // (that is a pan), by release, or by the map starting to move/zoom, so it
323
+ // never fires mid-gesture. Touch and mouse share one path.
324
+ let pressTimer: ReturnType<typeof setTimeout> | null = null;
325
+ let pressStart: { x: number; y: number } | null = null;
326
+ const SLOP_PX = 8;
327
+ const cancelPress = () => {
328
+ if (pressTimer) clearTimeout(pressTimer);
329
+ pressTimer = null;
330
+ pressStart = null;
331
+ };
332
+ const beginPress = (e: mapboxgl.MapMouseEvent | mapboxgl.MapTouchEvent) => {
333
+ if (!propsRef.current.onLongPress) return;
334
+ cancelPress();
335
+ const { lng, lat } = e.lngLat;
336
+ const point = e.point;
337
+ pressStart = { x: point.x, y: point.y };
338
+ pressTimer = setTimeout(() => {
339
+ pressTimer = null;
340
+ pressStart = null;
341
+ propsRef.current.onLongPress?.({ lon: lng, lat, x: point.x, y: point.y });
342
+ }, longPressMs);
343
+ };
344
+ const movePress = (e: mapboxgl.MapMouseEvent | mapboxgl.MapTouchEvent) => {
345
+ if (!pressStart) return;
346
+ if (Math.hypot(e.point.x - pressStart.x, e.point.y - pressStart.y) > SLOP_PX) cancelPress();
347
+ };
348
+ map.on("mousedown", beginPress);
349
+ map.on("touchstart", beginPress);
350
+ map.on("mousemove", movePress);
351
+ map.on("touchmove", movePress);
352
+ for (const ev of ["mouseup", "touchend", "touchcancel", "dragstart", "movestart", "zoomstart"] as const) {
353
+ map.on(ev, cancelPress);
354
+ }
355
+ // Suppress the browser context menu so a touch long-press feels native.
356
+ map.getCanvas().addEventListener("contextmenu", (ev) => ev.preventDefault());
357
+
358
+ if (!interactive) {
359
+ const ro = new ResizeObserver(() => {
360
+ map.resize();
361
+ fieldRef.current?.resize();
362
+ });
363
+ ro.observe(map.getContainer());
364
+ (map as unknown as { _gkRo?: ResizeObserver })._gkRo = ro;
365
+ }
366
+
367
+ onMap?.(map);
368
+ setReady(true);
369
+ refreshField();
370
+ drawArrows();
371
+ });
372
+
373
+ return () => {
374
+ fieldRef.current?.destroy();
375
+ markersRef.current.forEach((m) => m.remove());
376
+ markersRef.current = [];
377
+ const ro = (map as unknown as { _gkRo?: ResizeObserver })._gkRo;
378
+ ro?.disconnect();
379
+ map.remove();
380
+ mapRef.current = null;
381
+ };
382
+ // Camera identity — remount when the locked place changes.
383
+ // eslint-disable-next-line react-hooks/exhaustive-deps
384
+ }, [accessToken, styleURL, center[0], center[1], zoom, interactive]);
385
+
386
+ function refreshField() {
387
+ const map = mapRef.current;
388
+ const p = propsRef.current;
389
+ if (!map || !map.getLayer("gk-field")) return;
390
+ const showField = (p.layers.field ?? true) && hasColorField(p.product);
391
+ map.setLayoutProperty("gk-field", "visibility", showField ? "visible" : "none");
392
+ const el = map.getContainer();
393
+ const w = el.clientWidth;
394
+ const h = el.clientHeight;
395
+ if (w < 2 || h < 2 || p.timeMs <= 0) return;
396
+ const bounds = mapBounds(map);
397
+ if (bounds.west === bounds.east || bounds.north <= bounds.south) return;
398
+ const z = map.getZoom();
399
+ const gen = ((map as unknown as { _gkFieldGen?: number })._gkFieldGen ?? 0) + 1;
400
+ (map as unknown as { _gkFieldGen?: number })._gkFieldGen = gen;
401
+ const apply = (session: Awaited<ReturnType<typeof syncFieldSession>>) => {
402
+ if (mapRef.current !== map) return;
403
+ if ((map as unknown as { _gkFieldGen?: number })._gkFieldGen !== gen) return;
404
+ if (showField) {
405
+ const t0 = performance.now();
406
+ const url = fieldToDataUrl(session.field);
407
+ p.onFieldReady?.(Math.round(performance.now() - t0), session.field.width * session.field.height);
408
+ const src = map.getSource("gk-field") as mapboxgl.ImageSource | undefined;
409
+ if (url && src) src.updateImage({ url, coordinates: quadOf(session.padded) });
410
+ map.setPaintProperty("gk-field", "raster-opacity", p.opacity);
411
+ }
412
+ drawArrows();
413
+ };
414
+ const req: FieldSessionRequest = {
415
+ source: p.source,
416
+ product: p.product,
417
+ timeMs: p.timeMs,
418
+ bounds,
419
+ zoom: z,
420
+ size: { width: w, height: h },
421
+ pad: p.pad,
422
+ downsample: p.downsample,
423
+ validTimes: p.validTimes,
424
+ prefetchDelayMs: 800,
425
+ prefetchNeighbours: prefetchIsWorthIt(),
426
+ viewOptions: p.marginTiles != null ? { marginTiles: p.marginTiles } : undefined,
427
+ onPartial: apply,
428
+ };
429
+ void syncFieldSession(req).then(apply);
430
+ }
431
+
432
+ function clearArrows() {
433
+ const c = arrowsCanvasRef.current;
434
+ if (!c) return;
435
+ c.getContext("2d")?.clearRect(0, 0, c.width, c.height);
436
+ }
437
+
438
+ function drawArrows() {
439
+ const map = mapRef.current;
440
+ const canvas = arrowsCanvasRef.current;
441
+ const p = propsRef.current;
442
+ if (!map || !canvas) return;
443
+ const el = map.getContainer();
444
+ const w = el.clientWidth;
445
+ const h = el.clientHeight;
446
+ const dpr = window.devicePixelRatio || 1;
447
+ canvas.width = Math.round(w * dpr);
448
+ canvas.height = Math.round(h * dpr);
449
+ canvas.style.width = `${w}px`;
450
+ canvas.style.height = `${h}px`;
451
+ const ctx = canvas.getContext("2d");
452
+ if (!ctx) return;
453
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
454
+ ctx.clearRect(0, 0, w, h);
455
+ if (!p.layers.arrows || p.product === Product.Pressure || w < 2) return;
456
+ if (p.arrowMode === "sparse") {
457
+ const magMax = p.product === Product.Wind ? 25 : 8;
458
+ const spots = sparseArrowSpots(w, h, (px, py) => {
459
+ const ll = map.unproject([px, py]);
460
+ return vectorAt(p.source, p.product, ll.lng, ll.lat);
461
+ });
462
+ paintArrowSpots(ctx, spots, magMax, 3);
463
+ } else {
464
+ paintArrowGrid(ctx, w, h, p.source, p.product, (px, py) => {
465
+ const ll = map.unproject([px, py]);
466
+ return { lon: ll.lng, lat: ll.lat };
467
+ });
468
+ }
469
+ }
470
+
471
+ useEffect(() => {
472
+ if (ready) refreshField();
473
+ // eslint-disable-next-line react-hooks/exhaustive-deps
474
+ }, [ready, source, product, timeMs, opacity]);
475
+
476
+ useEffect(() => {
477
+ if (ready) drawArrows();
478
+ // eslint-disable-next-line react-hooks/exhaustive-deps
479
+ }, [ready, layers.arrows, product, arrowMode]);
480
+
481
+ useEffect(() => {
482
+ const field = fieldRef.current;
483
+ if (!ready || !field) return;
484
+ field.setSampler(source.vectorSampler(product) ?? (() => null));
485
+ field.setEnabled((layers.particles ?? true) && product !== Product.Pressure);
486
+ }, [ready, source, product, timeMs, layers.particles]);
487
+
488
+ useEffect(() => {
489
+ const map = mapRef.current;
490
+ if (!ready || !map || !map.getLayer("gk-isobars")) return;
491
+ const on = layers.isobars ?? false;
492
+ map.setLayoutProperty("gk-isobars", "visibility", on ? "visible" : "none");
493
+ if (!on) return;
494
+ let cancelled = false;
495
+ void source.fetchIsobars(timeMs).then((gj) => {
496
+ if (cancelled) return;
497
+ const src = map.getSource("gk-isobars") as mapboxgl.GeoJSONSource | undefined;
498
+ src?.setData(gj ?? EMPTY_FC);
499
+ });
500
+ return () => {
501
+ cancelled = true;
502
+ };
503
+ }, [ready, source, timeMs, layers.isobars]);
504
+
505
+ useEffect(() => {
506
+ const map = mapRef.current;
507
+ if (!ready || !map) return;
508
+ markersRef.current.forEach((m) => m.remove());
509
+ markersRef.current = [];
510
+ if (!layers.centers) return;
511
+ let cancelled = false;
512
+ void source.fetchCenters(timeMs).then((centers) => {
513
+ if (cancelled) return;
514
+ for (const c of centers) {
515
+ const el = document.createElement("div");
516
+ el.className = `gk-center gk-center-${c.kind === "H" ? "high" : "low"}`;
517
+ el.innerHTML = `<b>${c.kind}</b><span>${Math.round(c.pressureHpa)}</span>`;
518
+ markersRef.current.push(new mapboxgl.Marker({ element: el }).setLngLat([c.lon, c.lat]).addTo(map));
519
+ }
520
+ });
521
+ return () => {
522
+ cancelled = true;
523
+ };
524
+ }, [ready, source, timeMs, layers.centers]);
525
+
526
+ if (!accessToken) {
527
+ return (
528
+ <div className="gk-notoken">
529
+ Set a Mapbox public token (<code>pk.…</code>) to load the map.
530
+ </div>
531
+ );
532
+ }
533
+
534
+ return (
535
+ <div className={className ?? "gk-map-root"} style={{ position: "absolute", inset: 0 }}>
536
+ <div ref={containerRef} className="gk-map-canvas" />
537
+ {children ? <div className="gk-map-chrome">{children}</div> : null}
538
+ </div>
539
+ );
540
+ }
package/src/arrows.ts ADDED
@@ -0,0 +1,112 @@
1
+ import { vectorAt, type GustKitSource, type VectorSample } from "@gustkit/client";
2
+ import { Product } from "@gustkit/client";
3
+
4
+ export interface ArrowSpot {
5
+ x: number;
6
+ y: number;
7
+ dirU: number;
8
+ dirV: number;
9
+ mag: number;
10
+ }
11
+
12
+ /** 3×2 sample grid, inset from the postcard edges — at most `maxCount` arrows. */
13
+ export function sparseArrowSpots(
14
+ w: number,
15
+ h: number,
16
+ sample: (px: number, py: number) => VectorSample | null,
17
+ maxCount = 6,
18
+ ): ArrowSpot[] {
19
+ const cols = 3;
20
+ const rows = 2;
21
+ const insetX = w * 0.22;
22
+ const insetY = h * 0.28;
23
+ const spanX = cols > 1 ? (w - 2 * insetX) / (cols - 1) : 0;
24
+ const spanY = rows > 1 ? (h - 2 * insetY) / (rows - 1) : 0;
25
+ const out: ArrowSpot[] = [];
26
+ for (let r = 0; r < rows && out.length < maxCount; r++) {
27
+ for (let c = 0; c < cols && out.length < maxCount; c++) {
28
+ const x = insetX + c * spanX;
29
+ const y = insetY + r * spanY;
30
+ const v = sample(x, y);
31
+ if (!v || !(v.mag > 0)) continue;
32
+ out.push({ x, y, dirU: v.dirU, dirV: v.dirV, mag: v.mag });
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+
38
+ export function paintArrowSpots(
39
+ ctx: CanvasRenderingContext2D,
40
+ spots: ArrowSpot[],
41
+ magMax: number,
42
+ scale = 1,
43
+ ): void {
44
+ const color = "rgba(255,255,255,0.92)";
45
+ const stroke = Math.max(1.4, 1.5 * Math.sqrt(scale));
46
+ ctx.strokeStyle = color;
47
+ ctx.fillStyle = color;
48
+ ctx.lineWidth = stroke;
49
+ ctx.lineCap = "round";
50
+ ctx.lineJoin = "miter";
51
+ ctx.shadowColor = "rgba(0,0,0,0.55)";
52
+ ctx.shadowBlur = 2 * Math.sqrt(scale);
53
+ for (const s of spots) {
54
+ const len = (8 + Math.min(s.mag / magMax, 1) * 16) * scale;
55
+ const ang = Math.atan2(-s.dirV, s.dirU);
56
+ const hl = Math.min(len * 0.45, 7 * scale);
57
+ const hw = hl * 0.7;
58
+ const shaft = Math.max(stroke, len - hl);
59
+ const bx = s.x + Math.cos(ang) * shaft;
60
+ const by = s.y + Math.sin(ang) * shaft;
61
+ const ex = s.x + Math.cos(ang) * len;
62
+ const ey = s.y + Math.sin(ang) * len;
63
+ ctx.beginPath();
64
+ ctx.moveTo(s.x, s.y);
65
+ ctx.lineTo(bx, by);
66
+ ctx.stroke();
67
+ ctx.beginPath();
68
+ ctx.moveTo(ex, ey);
69
+ ctx.lineTo(bx + Math.sin(ang) * hw, by - Math.cos(ang) * hw);
70
+ ctx.lineTo(bx - Math.sin(ang) * hw, by + Math.cos(ang) * hw);
71
+ ctx.closePath();
72
+ ctx.fill();
73
+ }
74
+ ctx.shadowBlur = 0;
75
+ }
76
+
77
+ export function paintArrowGrid(
78
+ ctx: CanvasRenderingContext2D,
79
+ w: number,
80
+ h: number,
81
+ source: GustKitSource,
82
+ product: Product,
83
+ unproject: (px: number, py: number) => { lon: number; lat: number },
84
+ ): void {
85
+ const magMax = product === Product.Wind ? 25 : 8;
86
+ const step = 46;
87
+ ctx.strokeStyle = "rgba(255,255,255,0.9)";
88
+ ctx.lineWidth = 1.3;
89
+ ctx.lineCap = "round";
90
+ ctx.shadowColor = "rgba(0,0,0,0.55)";
91
+ ctx.shadowBlur = 2;
92
+ for (let py = step / 2; py < h; py += step) {
93
+ for (let px = step / 2; px < w; px += step) {
94
+ const ll = unproject(px, py);
95
+ const v = vectorAt(source, product, ll.lon, ll.lat);
96
+ if (!v || !(v.mag > 0)) continue;
97
+ const len = 6 + Math.min(v.mag / magMax, 1) * 16;
98
+ const ex = px + v.dirU * len;
99
+ const ey = py - v.dirV * len;
100
+ ctx.beginPath();
101
+ ctx.moveTo(px, py);
102
+ ctx.lineTo(ex, ey);
103
+ const ang = Math.atan2(ey - py, ex - px);
104
+ const hl = 4;
105
+ ctx.lineTo(ex - hl * Math.cos(ang - 0.5), ey - hl * Math.sin(ang - 0.5));
106
+ ctx.moveTo(ex, ey);
107
+ ctx.lineTo(ex - hl * Math.cos(ang + 0.5), ey - hl * Math.sin(ang + 0.5));
108
+ ctx.stroke();
109
+ }
110
+ }
111
+ ctx.shadowBlur = 0;
112
+ }
@@ -0,0 +1,14 @@
1
+ import type { RasterField } from "@gustkit/client";
2
+
3
+ /** Encode an RGBA field as a PNG data URL for a Mapbox ImageSource. */
4
+ export function fieldToDataUrl(field: RasterField): string | null {
5
+ if (field.width < 1 || field.height < 1 || field.rgba.length === 0) return null;
6
+ const canvas = document.createElement("canvas");
7
+ canvas.width = field.width;
8
+ canvas.height = field.height;
9
+ const ctx = canvas.getContext("2d");
10
+ if (!ctx) return null;
11
+ const img = new ImageData(new Uint8ClampedArray(field.rgba), field.width, field.height);
12
+ ctx.putImageData(img, 0, 0);
13
+ return canvas.toDataURL("image/png");
14
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { GustKitMap } from "./GustKitMap.js";
2
+ export type { GustKitMapProps, GustKitMapLayers, GustKitMapHover } from "./GustKitMap.js";
package/src/styles.ts ADDED
@@ -0,0 +1,25 @@
1
+ const CSS = `
2
+ .gk-map-root{position:relative;width:100%;height:100%}
3
+ .gk-map-canvas{position:absolute;inset:0}
4
+ .gk-overlay{position:absolute;top:0;left:0;pointer-events:none}
5
+ .gk-arrows{z-index:1}
6
+ .gk-particles{z-index:2}
7
+ .gk-map-chrome{position:absolute;inset:0;pointer-events:none;z-index:3}
8
+ .gk-center{display:flex;flex-direction:column;align-items:center;line-height:1;pointer-events:none;text-shadow:0 1px 2px rgba(0,0,0,.7);font-variant-numeric:tabular-nums}
9
+ .gk-center b{font-size:18px;font-weight:800}
10
+ .gk-center span{font-size:10px;color:#e5e7eb}
11
+ .gk-center-high b{color:#4a86e8}
12
+ .gk-center-low b{color:#e2555a}
13
+ .gk-notoken{display:flex;align-items:center;justify-content:center;color:#94a3b8;padding:24px;text-align:center}
14
+ `;
15
+
16
+ let injected = false;
17
+
18
+ export function ensureMapStyles(): void {
19
+ if (injected || typeof document === "undefined") return;
20
+ injected = true;
21
+ const el = document.createElement("style");
22
+ el.dataset.gustkit = "mapbox-web";
23
+ el.textContent = CSS;
24
+ document.head.appendChild(el);
25
+ }