@mapka/maplibre-gl-sdk 0.16.4 → 0.17.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.
@@ -1,10 +1,12 @@
1
1
  import type { MarkerOptions } from "maplibre-gl";
2
2
  import type { MapkaMarkerPopupOptions } from "./popup.js";
3
- export interface MapkaMarkerOptions extends MarkerOptions {
3
+ export interface MapkaMarkerOptions extends Omit<MarkerOptions, "color"> {
4
4
  id?: string;
5
5
  lngLat: [number, number];
6
- color?: string;
6
+ /** Mapka icon id (e.g. `"maki:restaurant"`). Omit to use the default pin. */
7
7
  icon?: string;
8
+ /** CSS color applied to the SVG fill. Default pin uses `#3FB1CE` when unset. */
9
+ color?: string;
8
10
  popup?: MapkaMarkerPopupOptions;
9
11
  }
10
12
  //# sourceMappingURL=marker.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"marker.d.ts","sourceRoot":"","sources":["../../src/types/marker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAE1D,MAAM,WAAW,kBAAmB,SAAQ,aAAa;IACvD,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,uBAAuB,CAAC;CACjC"}
1
+ {"version":3,"file":"marker.d.ts","sourceRoot":"","sources":["../../src/types/marker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAE1D,MAAM,WAAW,kBAAmB,SAAQ,IAAI,CAAC,aAAa,EAAE,OAAO,CAAC;IACtE,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACzB,6EAA6E;IAC7E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,gFAAgF;IAChF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,uBAAuB,CAAC;CACjC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mapka/maplibre-gl-sdk",
3
- "version": "0.16.4",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "description": "Mapka JS SDK",
6
6
  "sideEffects": false,
@@ -32,6 +32,7 @@
32
32
  "dependencies": {
33
33
  "es-toolkit": "^1.45.1",
34
34
  "html-to-image": "^1.11.13",
35
+ "ky": "^2.0.1",
35
36
  "maplibre-gl": "^5.22.0",
36
37
  "preact": "^10.29.1",
37
38
  "supercluster": "^8.0.1",
@@ -56,5 +57,5 @@
56
57
  "!**/__tests__/",
57
58
  "!lib/buildInfo.json"
58
59
  ],
59
- "gitHead": "5caa83c3e1e7607c5044132dfd0ba40449cf2bc3"
60
+ "gitHead": "205ea38efd4f49ec5a2f52267b76d19b9f5f1af9"
60
61
  }
@@ -1,10 +1,10 @@
1
1
  /* Mapka Popup Styles */
2
- .mapka-popup-container {
3
- margin-top: -15px;
4
- margin-bottom: -15px;
5
- margin-left: -10px;
6
- margin-right: -10px;
2
+ .mapka-popup-container {}
7
3
 
4
+ .maplibregl-popup-content {
5
+ padding: 0;
6
+ margin: 0;
7
+ border-radius: 12px;
8
8
  }
9
9
 
10
10
  .mapka-popup {
package/src/index.ts CHANGED
@@ -17,3 +17,5 @@ export {
17
17
  MapkaDrawControlOptions,
18
18
  DrawMode,
19
19
  } from "./controls/MapkaDrawControl.js";
20
+
21
+ export { loadMarkerIcon } from "./modules/icons.js";
@@ -1,57 +1,93 @@
1
+ import ky from "ky";
1
2
  import { debounce, uniq } from "es-toolkit";
2
3
  import { getMapkaUrl } from "../utils/url.js";
3
4
  import type { MapkaMap } from "../map.js";
4
5
  import type { MapStyleImageMissingEvent } from "maplibre-gl";
5
6
 
6
- let missingIcons: string[] = [];
7
- let loadedIcons: string[] = [];
8
-
9
7
  interface Icon {
10
8
  id: string;
11
9
  svg: string;
12
10
  }
13
11
 
12
+ const svgCache = new Map<string, string>();
13
+ const pendingResolvers = new Map<string, PromiseWithResolvers<string>>();
14
+ let pendingIds: string[] = [];
15
+
14
16
  /**
15
- * Load missing icons from Mapka API
16
- * Inspired by https://github.com/mapbox/mapbox-gl-js/issues/5529
17
+ * Debounced batch fetch: pulls unique pending ids, hits the icons API
18
+ * in one request, populates svgCache, and resolves waiting callers.
17
19
  */
18
- const loadIcons = debounce((map: MapkaMap) => {
19
- if (missingIcons.length === 0) {
20
- return;
21
- }
22
- const iconsToLoad = uniq(missingIcons).filter((id) => !loadedIcons.includes(id));
23
- loadedIcons = loadedIcons.concat(iconsToLoad);
24
- missingIcons = [];
25
-
26
- if (iconsToLoad.length === 0) {
27
- return;
28
- }
29
-
30
- const search = new URLSearchParams(iconsToLoad.map((id) => ["ids", id]));
31
- fetch(`${getMapkaUrl()}/v1/icons?${search}`)
32
- .then((response) => response.json())
33
- .then((data: Icon[]) => {
34
- data.forEach(({ svg, id }) => {
35
- const img = new Image(15, 15);
36
- img.onload = () => map.addImage(id, img);
37
- img.src = `data:image/svg+xml;charset=utf-8;base64,${btoa(svg)}`;
38
- });
20
+ const flushIcons = debounce(() => {
21
+ const idsToFetch = uniq(pendingIds).filter((id) => !svgCache.has(id));
22
+ if (idsToFetch.length === 0) return;
23
+
24
+ pendingIds = [];
25
+
26
+ ky.get(`${getMapkaUrl()}/v1/icons`, {
27
+ searchParams: idsToFetch.map((id) => ["ids", id]),
28
+ })
29
+ .json<Icon[]>()
30
+ .then((data) => {
31
+ for (const { id, svg } of data) {
32
+ svgCache.set(id, svg);
33
+ const resolver = pendingResolvers.get(id);
34
+ if (resolver) {
35
+ resolver.resolve(svg);
36
+ pendingResolvers.delete(id);
37
+ }
38
+ }
39
+ })
40
+ .catch((err) => {
41
+ for (const id of idsToFetch) {
42
+ const resolver = pendingResolvers.get(id);
43
+ if (resolver) {
44
+ resolver.reject(err);
45
+ pendingResolvers.delete(id);
46
+ }
47
+ }
39
48
  });
40
49
  }, 50);
41
50
 
42
51
  /**
43
- * Load any icons that are missing from the map
44
- * Only supports icons from Mapka API maki, temaki and tabler
45
- * @param map
46
- * @param event
52
+ * Fetch an icon's SVG source from the Mapka API.
53
+ * Batched and cached across callers; concurrent requests for the same
54
+ * id share one fetch.
55
+ */
56
+ export function loadMarkerIcon(id: string): Promise<string> {
57
+ const cached = svgCache.get(id);
58
+ if (cached) return Promise.resolve(cached);
59
+
60
+ const existing = pendingResolvers.get(id);
61
+ if (existing) return existing.promise;
62
+
63
+ const resolver = Promise.withResolvers<string>();
64
+ pendingResolvers.set(id, resolver);
65
+ pendingIds.push(id);
66
+ flushIcons();
67
+ return resolver.promise;
68
+ }
69
+
70
+ const isStyleImage = (id: string) => id.includes(":");
71
+
72
+ /**
73
+ * Load any icons that are missing from the map from the Mapka API.
74
+ * @see https://github.com/mapbox/mapbox-gl-js/issues/5529
47
75
  */
48
76
  export function loadLayersIcons(map: MapkaMap, event: MapStyleImageMissingEvent) {
49
- if (
50
- event.id.startsWith("maki:") ||
51
- event.id.startsWith("temaki:") ||
52
- event.id.startsWith("tabler:")
53
- ) {
54
- missingIcons.push(event.id);
55
- }
56
- loadIcons(map);
77
+ if (map.hasImage(event.id) || !isStyleImage(event.id)) return;
78
+
79
+ loadMarkerIcon(event.id)
80
+ .then((svg) => {
81
+ if (map.hasImage(event.id)) return;
82
+ const img = new Image(15, 15);
83
+ img.onload = () => {
84
+ if (!map.hasImage(event.id)) {
85
+ map.addImage(event.id, img);
86
+ }
87
+ };
88
+ img.src = `data:image/svg+xml;charset=utf-8;base64,${btoa(svg)}`;
89
+ })
90
+ .catch((err) => {
91
+ map.logger.warn(`[mapka] Failed to load layer icon "${event.id}":`, err);
92
+ });
57
93
  }
@@ -0,0 +1,18 @@
1
+ .mapka-marker-icon {
2
+ display: flex;
3
+ align-items: center;
4
+ justify-content: center;
5
+ pointer-events: auto;
6
+ }
7
+
8
+ /* Icon markers (maki / temaki) render in a fixed 32×32 box. */
9
+ .mapka-marker-icon[data-icon-id] {
10
+ width: 32px;
11
+ height: 32px;
12
+ }
13
+
14
+ .mapka-marker-icon[data-icon-id] svg {
15
+ width: 100%;
16
+ height: 100%;
17
+ display: block;
18
+ }
@@ -1,11 +1,18 @@
1
1
  import { Marker } from "maplibre-gl";
2
2
  import { get } from "es-toolkit/compat";
3
3
  import { remove } from "es-toolkit";
4
- import type { Offset, StyleSpecification } from "maplibre-gl";
4
+ import { loadMarkerIcon } from "./icons.js";
5
+ import type { MarkerOptions, Offset, StyleSpecification } from "maplibre-gl";
5
6
  import type { MapkaMap } from "../map.js";
6
7
  import type { MapkaMarkerOptions } from "../types/marker.js";
7
8
  import type { MapkaMarkerPopupOptions, MapkaPopupOptions } from "../types/popup.js";
8
9
 
10
+ /**
11
+ * Offset for the default pin so the tip sits on the LngLat anchor point.
12
+ * Value taken from maplibre-gl-js: (shadow translate-y + ellipse cy) - (height/2) ≈ 14.
13
+ */
14
+ const DEFAULT_PIN_OFFSET: [number, number] = [0, -14];
15
+
9
16
  /**
10
17
  * Default marker offset
11
18
  * @see https://github.com/maplibre/maplibre-gl-js/blob/master/src/ui/marker.ts#L457
@@ -110,10 +117,67 @@ function setupMarkerPopupListeners(
110
117
  }
111
118
  }
112
119
 
120
+ /**
121
+ * Apply `color` (as SVG `fill`) to the fetched icon SVG inside `element`.
122
+ *
123
+ * Override every descendant whose `fill` attribute is set and not `"none"`,
124
+ * then set `fill` on the root `<svg>` itself so paths that omit the
125
+ * attribute (common in maki — they inherit the browser default) pick up the
126
+ * user color via SVG cascading. `fill="none"` is preserved so outline-only
127
+ * shapes keep their transparency.
128
+ *
129
+ * Only invoked for icon markers; the default maplibre pin handles its own
130
+ * color via `MarkerOptions.color`.
131
+ */
132
+ function applyMarkerColors(element: HTMLElement, color?: string) {
133
+ if (!color) {
134
+ return;
135
+ }
136
+ for (const el of element.querySelectorAll('[fill]:not([fill="none"])')) {
137
+ el.setAttribute("fill", color);
138
+ }
139
+ const svg = element.querySelector("svg");
140
+ if (svg && !svg.hasAttribute("fill")) {
141
+ svg.setAttribute("fill", color);
142
+ }
143
+ }
144
+
145
+ function createMarkerElement(
146
+ currentMap: MapkaMap,
147
+ options: MapkaMarkerOptions,
148
+ ): HTMLElement | undefined {
149
+ const { color, icon } = options;
150
+ if (!icon) {
151
+ return;
152
+ }
153
+
154
+ const element = document.createElement("div");
155
+ element.className = "mapka-marker-icon";
156
+
157
+ element.dataset.iconId = icon;
158
+ loadMarkerIcon(icon)
159
+ .then((svg) => {
160
+ element.innerHTML = svg;
161
+ applyMarkerColors(element, color);
162
+ })
163
+ .catch((err) => {
164
+ currentMap.logger.warn(`[mapka] Failed to load marker icon "${icon}":`, err);
165
+ });
166
+
167
+ return element;
168
+ }
169
+
113
170
  export function addMarkers(currentMap: MapkaMap, markersOptions: MapkaMarkerOptions[]) {
114
171
  for (const markerOptions of markersOptions) {
115
- const { lngLat, popup, ...options } = markerOptions;
116
- const newMarker = new Marker(options).setLngLat(lngLat).addTo(currentMap);
172
+ const { lngLat, popup, icon, offset = DEFAULT_PIN_OFFSET, ...rest } = markerOptions;
173
+
174
+ const markerOpts: MarkerOptions = {
175
+ ...rest,
176
+ element: createMarkerElement(currentMap, markerOptions),
177
+ offset,
178
+ };
179
+
180
+ const newMarker = new Marker(markerOpts).setLngLat(lngLat).addTo(currentMap);
117
181
 
118
182
  currentMap.markers.push({
119
183
  id: getMarkerId(markerOptions),
package/src/styles.css CHANGED
@@ -5,3 +5,4 @@
5
5
  @import "./components/PopupDataRows.css";
6
6
  @import "./components/PopupContent.css";
7
7
  @import "./controls/MapkaDrawControl.css";
8
+ @import "./modules/markers.css";
@@ -1,10 +1,12 @@
1
1
  import type { MarkerOptions } from "maplibre-gl";
2
2
  import type { MapkaMarkerPopupOptions } from "./popup.js";
3
3
 
4
- export interface MapkaMarkerOptions extends MarkerOptions {
4
+ export interface MapkaMarkerOptions extends Omit<MarkerOptions, "color"> {
5
5
  id?: string;
6
6
  lngLat: [number, number];
7
- color?: string;
7
+ /** Mapka icon id (e.g. `"maki:restaurant"`). Omit to use the default pin. */
8
8
  icon?: string;
9
+ /** CSS color applied to the SVG fill. Default pin uses `#3FB1CE` when unset. */
10
+ color?: string;
9
11
  popup?: MapkaMarkerPopupOptions;
10
12
  }