@orbat-mapper/tactical-draw-adapter-maplibre 0.2.0-alpha.9 → 0.2.1
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/dist/index.d.mts +56 -3
- package/dist/index.mjs +793 -76
- package/package.json +6 -5
package/dist/index.d.mts
CHANGED
|
@@ -1,14 +1,38 @@
|
|
|
1
|
-
import { BaseMapAdapter, EditPointerDriver,
|
|
1
|
+
import { BaseMapAdapter, EditPointerDriver, FeatureGroup, IconResource, LayerId, MapCursor, NativeEventEntry, PickHandler, PickOptions, PixelCoordinate, StyleOptions } from "@orbat-mapper/tactical-draw";
|
|
2
2
|
import maplibregl from "maplibre-gl";
|
|
3
|
-
import { Position } from "geojson";
|
|
3
|
+
import { Feature, Position } from "geojson";
|
|
4
4
|
|
|
5
5
|
//#region src/ml-adapter.d.ts
|
|
6
6
|
interface MapLibreLayerHandle {
|
|
7
7
|
sourceId: string;
|
|
8
8
|
/** MapLibre layer ids created for this logical layer (fill, line, circle, symbol) */
|
|
9
9
|
mlLayerIds: string[];
|
|
10
|
+
groupBundles: Map<string, MapLibreGroupBundle>;
|
|
11
|
+
groupOrder: string[];
|
|
12
|
+
nextGroupBundleId: number;
|
|
10
13
|
style?: StyleOptions;
|
|
11
14
|
}
|
|
15
|
+
type PaintChannel = "fill" | "fill-pattern" | "line" | "circle" | "icon" | "text";
|
|
16
|
+
interface MapLibreGroupBundle {
|
|
17
|
+
readonly sourceId: string;
|
|
18
|
+
readonly layerIds: Map<PaintChannel, string>;
|
|
19
|
+
features: readonly Feature[];
|
|
20
|
+
/** Features most recently submitted to the native source, after MapLibre-specific promotion. */
|
|
21
|
+
sourceFeatures: readonly Feature[];
|
|
22
|
+
/** Whether the native source has received its first complete dataset. */
|
|
23
|
+
sourceInitialized: boolean;
|
|
24
|
+
/** Unset while the stored value describes older features than `features` holds. */
|
|
25
|
+
featureSignature: string | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Last `FeatureGroup.revision` seen for this bundle, when the producer
|
|
28
|
+
* supplied one. Two consecutive groups with the same `revision` are known
|
|
29
|
+
* to carry byte-identical `features`, so it stands in for `featureSignature`
|
|
30
|
+
* without paying to re-stringify the whole group. A missing revision on
|
|
31
|
+
* either side falls back to the signature comparison — see `applyFeatureGroups`.
|
|
32
|
+
*/
|
|
33
|
+
revision?: number;
|
|
34
|
+
requiredIconIds: ReadonlySet<string>;
|
|
35
|
+
}
|
|
12
36
|
type MapLibreEventToken = (e: any) => void;
|
|
13
37
|
interface MapLibreViewToken {
|
|
14
38
|
fn: () => void;
|
|
@@ -30,7 +54,33 @@ declare class MapLibreAdapter extends BaseMapAdapter<MapLibreLayerHandle, MapLib
|
|
|
30
54
|
private toContinuousLng;
|
|
31
55
|
protected createLayer(layerId: LayerId, style?: StyleOptions): MapLibreLayerHandle;
|
|
32
56
|
protected destroyLayer(handle: MapLibreLayerHandle): void;
|
|
33
|
-
protected
|
|
57
|
+
protected applyFeatureGroups(layerId: LayerId, groups: readonly FeatureGroup[]): void;
|
|
58
|
+
private createGroupBundle;
|
|
59
|
+
private destroyGroupBundle;
|
|
60
|
+
private clearGroupBundles;
|
|
61
|
+
private requiredIconIds;
|
|
62
|
+
private reconcileGroupChannels;
|
|
63
|
+
private buildGroupLayer;
|
|
64
|
+
private updateGroupBundleSource;
|
|
65
|
+
private orderGroupBundles;
|
|
66
|
+
private nativeLayerIds;
|
|
67
|
+
/**
|
|
68
|
+
* Promote a source feature for MapLibre: stamp `_fid` (so data-driven paint
|
|
69
|
+
* and picks can key on the caller's feature id) and, for a hatch-patterned
|
|
70
|
+
* Polygon, `_fillPatternImageId` (registering the tile image on demand). At
|
|
71
|
+
* most one object spread — a feature needing neither passes through
|
|
72
|
+
* untouched.
|
|
73
|
+
*/
|
|
74
|
+
private promoteFeature;
|
|
75
|
+
/**
|
|
76
|
+
* Resolve a hatch-patterned Polygon's `style.fillPattern`/`fillColor` to its
|
|
77
|
+
* hatch-tile image id, registering the image on demand. Returns `undefined`
|
|
78
|
+
* for features without a hatch pattern, so `fillPatternImageId` stays the
|
|
79
|
+
* single source of truth for the id baked into `_fillPatternImageId`.
|
|
80
|
+
*/
|
|
81
|
+
private resolveFillPatternImage;
|
|
82
|
+
/** Register a hatch-tile image once per (color, pattern) pair, keyed by `fillPatternImageId`. */
|
|
83
|
+
private ensureFillPatternImage;
|
|
34
84
|
protected applyIconResources(resources: readonly IconResource[]): void;
|
|
35
85
|
protected removeIconResource(id: string): void;
|
|
36
86
|
protected attachNativeEvent(type: "click" | "pointermove" | "dblclick", onEvent: (lonLat: Position, pixel: PixelCoordinate, originalEvent: unknown) => void): NativeEventEntry<MapLibreEventToken>;
|
|
@@ -40,9 +90,12 @@ declare class MapLibreAdapter extends BaseMapAdapter<MapLibreLayerHandle, MapLib
|
|
|
40
90
|
private viewChangeEvents;
|
|
41
91
|
createEditPointerDriver(): EditPointerDriver;
|
|
42
92
|
setLayerStyle(layerId: LayerId, style: StyleOptions): void;
|
|
93
|
+
private legacyPaintChannel;
|
|
94
|
+
private applyChannelStyle;
|
|
43
95
|
toLonLat(coordinate: number[]): Position;
|
|
44
96
|
fromLonLat(position: Position): number[];
|
|
45
97
|
getPixelFromCoordinate(position: Position): PixelCoordinate | null;
|
|
98
|
+
getCoordinateFromPixel(pixel: PixelCoordinate): Position | null;
|
|
46
99
|
getViewportSize(): {
|
|
47
100
|
width: number;
|
|
48
101
|
height: number;
|
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,115 @@
|
|
|
1
1
|
import { project, unproject } from "@orbat-mapper/control-measures";
|
|
2
|
-
import { BaseMapAdapter, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, PointerCallbackHub, TOUCH_HIT_TOLERANCE_PX, bindAbortable, nearestWorldCopyLng, pickHitTolerance,
|
|
2
|
+
import { BaseMapAdapter, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, HATCH_PATTERNS, PointerCallbackHub, TEXT_HALO_COLOR, TOUCH_HIT_TOLERANCE_PX, bindAbortable, evaluatePickCandidates, fillPatternImageId, makeFillPatternImage, mergeNativeAndCachedPickFeatures, nearestWorldCopyLng, pickHitTolerance, pickPointerType } from "@orbat-mapper/tactical-draw";
|
|
3
3
|
//#region src/ml-adapter.ts
|
|
4
|
+
/** Feature property key for the resolved hatch-pattern image id (see `promoteFeature`). */
|
|
5
|
+
const FILL_PATTERN_IMAGE_ID_PROPERTY = "_fillPatternImageId";
|
|
6
|
+
const DEFAULT_LINE_COLOR = "#000";
|
|
7
|
+
const DEFAULT_LINE_WIDTH = 2;
|
|
8
|
+
function isHatchPattern(value) {
|
|
9
|
+
return HATCH_PATTERNS.includes(value);
|
|
10
|
+
}
|
|
11
|
+
const PAINT_CHANNELS = [
|
|
12
|
+
"fill",
|
|
13
|
+
"fill-pattern",
|
|
14
|
+
"line",
|
|
15
|
+
"circle",
|
|
16
|
+
"icon",
|
|
17
|
+
"text"
|
|
18
|
+
];
|
|
19
|
+
function firstBundleLayerId(bundle) {
|
|
20
|
+
for (const channel of PAINT_CHANNELS) {
|
|
21
|
+
const layerId = bundle.layerIds.get(channel);
|
|
22
|
+
if (layerId) return layerId;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function sameOrder(a, b) {
|
|
26
|
+
if (a.length !== b.length) return false;
|
|
27
|
+
for (let index = 0; index < a.length; index++) if (a[index] !== b[index]) return false;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Replace the contents of shared-id features while adding/removing ids that
|
|
32
|
+
* entered or left a stable group bundle. Remove stale keys explicitly instead
|
|
33
|
+
* of combining `removeAllProperties` with `addOrUpdateProperties`: MapLibre's
|
|
34
|
+
* tile-index diff treats the former as exclusive and otherwise drops the
|
|
35
|
+
* replacement properties, including the promoted feature id.
|
|
36
|
+
*/
|
|
37
|
+
function buildGeoJSONSourceDiff(previousFeatures, nextFeatures) {
|
|
38
|
+
const previousById = new Map(previousFeatures.map((feature) => [feature.id, feature]));
|
|
39
|
+
const nextById = new Map(nextFeatures.map((feature) => [feature.id, feature]));
|
|
40
|
+
const remove = previousFeatures.filter((feature) => !nextById.has(feature.id)).map((feature) => feature.id);
|
|
41
|
+
const add = [];
|
|
42
|
+
const update = [];
|
|
43
|
+
for (const feature of nextFeatures) {
|
|
44
|
+
const previous = previousById.get(feature.id);
|
|
45
|
+
if (!previous) {
|
|
46
|
+
add.push(feature);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (feature.geometry === null) {
|
|
50
|
+
remove.push(feature.id);
|
|
51
|
+
add.push(feature);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const previousProperties = previous.properties ?? {};
|
|
55
|
+
const nextProperties = feature.properties ?? {};
|
|
56
|
+
const removeProperties = Object.keys(previousProperties).filter((key) => !Object.hasOwn(nextProperties, key));
|
|
57
|
+
update.push({
|
|
58
|
+
id: feature.id,
|
|
59
|
+
newGeometry: feature.geometry,
|
|
60
|
+
...removeProperties.length > 0 ? { removeProperties } : {},
|
|
61
|
+
addOrUpdateProperties: Object.entries(nextProperties).map(([key, value]) => ({
|
|
62
|
+
key,
|
|
63
|
+
value
|
|
64
|
+
}))
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
...remove.length > 0 ? { remove } : {},
|
|
69
|
+
...add.length > 0 ? { add } : {},
|
|
70
|
+
...update.length > 0 ? { update } : {}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Find the largest subset of bundles whose relative order already matches the
|
|
75
|
+
* desired stack. Reorders then move only the remaining bundles.
|
|
76
|
+
*/
|
|
77
|
+
function longestStableGroupIds(previousGroupIds, desiredGroupIds) {
|
|
78
|
+
const previousIndices = new Map(previousGroupIds.map((groupId, index) => [groupId, index]));
|
|
79
|
+
const sequence = desiredGroupIds.flatMap((groupId) => {
|
|
80
|
+
const previousIndex = previousIndices.get(groupId);
|
|
81
|
+
return previousIndex === void 0 ? [] : [{
|
|
82
|
+
groupId,
|
|
83
|
+
previousIndex
|
|
84
|
+
}];
|
|
85
|
+
});
|
|
86
|
+
const tails = [];
|
|
87
|
+
const tailSequenceIndices = [];
|
|
88
|
+
const predecessors = new Array(sequence.length).fill(-1);
|
|
89
|
+
for (let sequenceIndex = 0; sequenceIndex < sequence.length; sequenceIndex++) {
|
|
90
|
+
const previousIndex = sequence[sequenceIndex].previousIndex;
|
|
91
|
+
let low = 0;
|
|
92
|
+
let high = tails.length;
|
|
93
|
+
while (low < high) {
|
|
94
|
+
const middle = low + high >>> 1;
|
|
95
|
+
if (tails[middle] < previousIndex) low = middle + 1;
|
|
96
|
+
else high = middle;
|
|
97
|
+
}
|
|
98
|
+
if (low > 0) predecessors[sequenceIndex] = tailSequenceIndices[low - 1];
|
|
99
|
+
tails[low] = previousIndex;
|
|
100
|
+
tailSequenceIndices[low] = sequenceIndex;
|
|
101
|
+
}
|
|
102
|
+
const stableGroupIds = /* @__PURE__ */ new Set();
|
|
103
|
+
let sequenceIndex = tailSequenceIndices.at(-1);
|
|
104
|
+
while (sequenceIndex !== void 0 && sequenceIndex >= 0) {
|
|
105
|
+
stableGroupIds.add(sequence[sequenceIndex].groupId);
|
|
106
|
+
sequenceIndex = predecessors[sequenceIndex];
|
|
107
|
+
}
|
|
108
|
+
return stableGroupIds;
|
|
109
|
+
}
|
|
110
|
+
function isPromiseLike(value) {
|
|
111
|
+
return (typeof value === "object" || typeof value === "function") && value !== null && typeof value.then === "function";
|
|
112
|
+
}
|
|
4
113
|
function emptyFC() {
|
|
5
114
|
return {
|
|
6
115
|
type: "FeatureCollection",
|
|
@@ -93,7 +202,8 @@ function buildIconLayer(id, source) {
|
|
|
93
202
|
"icon-rotation-alignment": "viewport",
|
|
94
203
|
"icon-pitch-alignment": "viewport",
|
|
95
204
|
"icon-allow-overlap": true,
|
|
96
|
-
"icon-ignore-placement": true
|
|
205
|
+
"icon-ignore-placement": true,
|
|
206
|
+
"symbol-z-order": "source"
|
|
97
207
|
},
|
|
98
208
|
paint: { "icon-opacity": [
|
|
99
209
|
"coalesce",
|
|
@@ -122,7 +232,7 @@ const WEB_MERCATOR_CIRCUMFERENCE = 40075016.686;
|
|
|
122
232
|
* matching `getResolution`); the linear ramp between stops approximates the
|
|
123
233
|
* exponential exactly closely enough at 1-zoom granularity.
|
|
124
234
|
*/
|
|
125
|
-
function
|
|
235
|
+
function textSizeAtZoom(text, zoom) {
|
|
126
236
|
const minPx = text.textSizeMinPixels ?? DEFAULT_TEXT_SIZE_MIN_PIXELS;
|
|
127
237
|
const maxPx = text.textSizeMaxPixels ?? DEFAULT_TEXT_SIZE_MAX_PIXELS;
|
|
128
238
|
const screenOrDefault = [
|
|
@@ -130,33 +240,97 @@ function buildTextSizeExpression(text) {
|
|
|
130
240
|
["get", "textSizePixels"],
|
|
131
241
|
14
|
|
132
242
|
];
|
|
133
|
-
const
|
|
134
|
-
|
|
243
|
+
const groundPx = [
|
|
244
|
+
"*",
|
|
245
|
+
["get", "textSizeMeters"],
|
|
246
|
+
Math.pow(2, zoom + 9) / WEB_MERCATOR_CIRCUMFERENCE
|
|
247
|
+
];
|
|
248
|
+
return [
|
|
249
|
+
"case",
|
|
250
|
+
["has", "textSizeMeters"],
|
|
251
|
+
[
|
|
135
252
|
"case",
|
|
136
|
-
["has", "
|
|
253
|
+
["has", "textMaxSizePixels"],
|
|
254
|
+
[
|
|
255
|
+
"min",
|
|
256
|
+
["get", "textMaxSizePixels"],
|
|
257
|
+
groundPx
|
|
258
|
+
],
|
|
137
259
|
[
|
|
138
260
|
"min",
|
|
139
261
|
maxPx,
|
|
140
262
|
[
|
|
141
263
|
"max",
|
|
142
264
|
minPx,
|
|
265
|
+
groundPx
|
|
266
|
+
]
|
|
267
|
+
]
|
|
268
|
+
],
|
|
269
|
+
screenOrDefault
|
|
270
|
+
];
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Wrap a per-zoom expression builder in the top-level zoom `interpolate` every
|
|
274
|
+
* zoom-driven property here needs. MapLibre only accepts `["zoom"]` as the
|
|
275
|
+
* input of a top-level `step`/`interpolate`, never nested inside a `case`, so
|
|
276
|
+
* the ramp must be outermost and the per-feature branching evaluated at each
|
|
277
|
+
* stop — not the other way round.
|
|
278
|
+
*/
|
|
279
|
+
function interpolateOverZoom(at) {
|
|
280
|
+
return [
|
|
281
|
+
"interpolate",
|
|
282
|
+
["linear"],
|
|
283
|
+
["zoom"],
|
|
284
|
+
...Array.from({ length: 25 }, (_, zoom) => [zoom, at(zoom)]).flat()
|
|
285
|
+
];
|
|
286
|
+
}
|
|
287
|
+
function buildTextSizeExpression(text) {
|
|
288
|
+
return interpolateOverZoom((zoom) => textSizeAtZoom(text, zoom));
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Companion to `buildTextSizeExpression` (ADR-0037): drives `text-opacity` to
|
|
292
|
+
* 0 once a ground-anchored Text graphic's derived on-screen size exceeds its
|
|
293
|
+
* `textMaxSizePixels` threshold, 1 otherwise (including every feature without
|
|
294
|
+
* a threshold, e.g. amplifier labels). Layout properties like `text-size`
|
|
295
|
+
* cannot represent "hidden", so hiding is a paint-property concern; `step`
|
|
296
|
+
* (rather than `interpolate`) is enough since opacity only ever needs to
|
|
297
|
+
* flip at a stop, evaluated at the same per-zoom granularity as the size
|
|
298
|
+
* expression.
|
|
299
|
+
*/
|
|
300
|
+
const TEXT_OPACITY_EXPRESSION = (() => {
|
|
301
|
+
const opacityAtZoom = (zoom) => {
|
|
302
|
+
return [
|
|
303
|
+
"case",
|
|
304
|
+
[
|
|
305
|
+
"all",
|
|
306
|
+
["has", "textSizeMeters"],
|
|
307
|
+
["has", "textMaxSizePixels"]
|
|
308
|
+
],
|
|
309
|
+
[
|
|
310
|
+
"case",
|
|
311
|
+
[
|
|
312
|
+
">",
|
|
143
313
|
[
|
|
144
314
|
"*",
|
|
145
315
|
["get", "textSizeMeters"],
|
|
146
316
|
Math.pow(2, zoom + 9) / WEB_MERCATOR_CIRCUMFERENCE
|
|
147
|
-
]
|
|
148
|
-
|
|
317
|
+
],
|
|
318
|
+
["get", "textMaxSizePixels"]
|
|
319
|
+
],
|
|
320
|
+
0,
|
|
321
|
+
1
|
|
149
322
|
],
|
|
150
|
-
|
|
323
|
+
1
|
|
151
324
|
];
|
|
152
325
|
};
|
|
326
|
+
const stops = Array.from({ length: 25 }, (_, zoom) => zoom);
|
|
153
327
|
return [
|
|
154
|
-
"
|
|
155
|
-
["linear"],
|
|
328
|
+
"step",
|
|
156
329
|
["zoom"],
|
|
157
|
-
|
|
330
|
+
opacityAtZoom(0),
|
|
331
|
+
...stops.slice(1).flatMap((zoom) => [zoom, opacityAtZoom(zoom)])
|
|
158
332
|
];
|
|
159
|
-
}
|
|
333
|
+
})();
|
|
160
334
|
/**
|
|
161
335
|
* Per-feature icon size, same zoom-stop scheme as `buildTextSizeExpression`:
|
|
162
336
|
* a ground-sized point symbol carries `iconSizeMeters`/`iconOctagonSize` (and
|
|
@@ -216,6 +390,133 @@ function buildIconSizeExpression() {
|
|
|
216
390
|
* `properties.style.fillColor` overlay. Mirrors `buildCirclePaint`.
|
|
217
391
|
*/
|
|
218
392
|
/**
|
|
393
|
+
* Per-feature `text-field` value: the layer's literal text, or the feature's
|
|
394
|
+
* `labelProperty` value, uppercased when the feature's `textStyle` is
|
|
395
|
+
* `"caps"`. Factored out of `buildSymbolLayer` so `setLayerStyle`'s
|
|
396
|
+
* incremental `text-field` update (below) can reuse it instead of
|
|
397
|
+
* regressing to a caps-unaware plain `["get", ...]`.
|
|
398
|
+
*/
|
|
399
|
+
function buildTextFieldExpression(text) {
|
|
400
|
+
const rawTextField = text.labelProperty ? ["get", text.labelProperty] : text.text ?? "";
|
|
401
|
+
return text.labelProperty ? [
|
|
402
|
+
"case",
|
|
403
|
+
[
|
|
404
|
+
"==",
|
|
405
|
+
["get", "textStyle"],
|
|
406
|
+
"caps"
|
|
407
|
+
],
|
|
408
|
+
["upcase", rawTextField],
|
|
409
|
+
rawTextField
|
|
410
|
+
] : rawTextField;
|
|
411
|
+
}
|
|
412
|
+
const HAS_TEXT_HALO = [
|
|
413
|
+
"boolean",
|
|
414
|
+
["get", "textHalo"],
|
|
415
|
+
false
|
|
416
|
+
];
|
|
417
|
+
const HAS_TEXT_BACKGROUND = [
|
|
418
|
+
"boolean",
|
|
419
|
+
["get", "textBackground"],
|
|
420
|
+
false
|
|
421
|
+
];
|
|
422
|
+
/**
|
|
423
|
+
* Per-feature `text-halo-color` value: `textHalo` wins over both the layer's
|
|
424
|
+
* `text.strokeColor` and the `textBackground` halo, matching the OL/Leaflet
|
|
425
|
+
* adapters' precedence. Factored out so `setLayerStyle`'s incremental
|
|
426
|
+
* `text-halo-color` update (below) can reuse it instead of dropping the
|
|
427
|
+
* `textHalo` case.
|
|
428
|
+
*/
|
|
429
|
+
function buildTextHaloColorExpression(text) {
|
|
430
|
+
const fallback = text.strokeColor ? text.strokeColor : [
|
|
431
|
+
"case",
|
|
432
|
+
HAS_TEXT_BACKGROUND,
|
|
433
|
+
"#ffffff",
|
|
434
|
+
"rgba(255,255,255,0)"
|
|
435
|
+
];
|
|
436
|
+
return [
|
|
437
|
+
"case",
|
|
438
|
+
HAS_TEXT_HALO,
|
|
439
|
+
[
|
|
440
|
+
"coalesce",
|
|
441
|
+
["get", "textHaloColor"],
|
|
442
|
+
TEXT_HALO_COLOR
|
|
443
|
+
],
|
|
444
|
+
fallback
|
|
445
|
+
];
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Per-feature `text-halo-width` value, mirroring
|
|
449
|
+
* `buildTextHaloColorExpression`'s precedence. MapLibre's `text-halo-width`
|
|
450
|
+
* already extends outward from the glyph edge, matching
|
|
451
|
+
* `resolveTextHaloWidth`'s own outward-depth semantics, so the `textHalo`
|
|
452
|
+
* case uses its formula directly (unlike OpenLayers's straddling canvas
|
|
453
|
+
* stroke, which must double it).
|
|
454
|
+
*
|
|
455
|
+
* The width is derived from `textSizeAtZoom` — the same size `text-size`
|
|
456
|
+
* renders at — not from `textSizePixels` alone. A ground-anchored label
|
|
457
|
+
* (ADR-0028) carries only `textSizeMeters`, so reading the pixel size directly
|
|
458
|
+
* would fall back to a constant and pin the halo at one width while the glyphs
|
|
459
|
+
* grew and shrank with zoom: far too heavy on small text, hairline on large.
|
|
460
|
+
* OL and Leaflet already halo off their resolved size; this brings MapLibre in
|
|
461
|
+
* line. The ratio stays well under MapLibre's SDF halo ceiling (~¼ em), so the
|
|
462
|
+
* halo never clips into blocky corners.
|
|
463
|
+
*/
|
|
464
|
+
function buildTextHaloWidthExpression(text) {
|
|
465
|
+
const fallback = text.strokeColor ? text.strokeWidth ?? 1 : [
|
|
466
|
+
"case",
|
|
467
|
+
HAS_TEXT_BACKGROUND,
|
|
468
|
+
3,
|
|
469
|
+
0
|
|
470
|
+
];
|
|
471
|
+
return interpolateOverZoom((zoom) => [
|
|
472
|
+
"case",
|
|
473
|
+
HAS_TEXT_HALO,
|
|
474
|
+
haloWidthAtZoom(text, zoom),
|
|
475
|
+
fallback
|
|
476
|
+
]);
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* `text-halo-blur` as a fraction of the halo width. MapLibre's halo edge is
|
|
480
|
+
* hard at blur 0, which reads harsher over a vector basemap than the basemap's
|
|
481
|
+
* own labels — OpenFreeMap Liberty, the preview's default style, blurs nearly
|
|
482
|
+
* every one of its halos. Scaled off the width rather than a flat pixel value
|
|
483
|
+
* so the softness stays proportional as the halo grows with zoom.
|
|
484
|
+
*
|
|
485
|
+
* MapLibre-only: OpenLayers canvas strokes have no blur, and Leaflet's stacked
|
|
486
|
+
* `text-shadow` halo would need a blur radius threaded through each shadow.
|
|
487
|
+
* The three engines' halos therefore differ slightly at the edge.
|
|
488
|
+
*/
|
|
489
|
+
const TEXT_HALO_BLUR_RATIO = .25;
|
|
490
|
+
/** The halo's outward depth at one zoom stop; shared by the width and blur builders. */
|
|
491
|
+
function haloWidthAtZoom(text, zoom) {
|
|
492
|
+
return [
|
|
493
|
+
"max",
|
|
494
|
+
1.5,
|
|
495
|
+
[
|
|
496
|
+
"/",
|
|
497
|
+
textSizeAtZoom(text, zoom),
|
|
498
|
+
8
|
|
499
|
+
]
|
|
500
|
+
];
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Per-feature `text-halo-blur`, softening only a `textHalo` halo's edge.
|
|
504
|
+
* Everything else stays at 0 so the `textBackground` backdrop and a
|
|
505
|
+
* layer-level text stroke keep the crisp edge they render today.
|
|
506
|
+
*/
|
|
507
|
+
function buildTextHaloBlurExpression(text) {
|
|
508
|
+
return interpolateOverZoom((zoom) => [
|
|
509
|
+
"case",
|
|
510
|
+
HAS_TEXT_HALO,
|
|
511
|
+
[
|
|
512
|
+
"*",
|
|
513
|
+
TEXT_HALO_BLUR_RATIO,
|
|
514
|
+
haloWidthAtZoom(text, zoom)
|
|
515
|
+
],
|
|
516
|
+
0
|
|
517
|
+
]);
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
219
520
|
* Build the layout and paint objects for a symbol (text) layer.
|
|
220
521
|
* Centralises the rotation convention so `createLayer` and `setLayerStyle`
|
|
221
522
|
* stay in sync. Rotation follows the OpenLayers convention: `π − r` radians
|
|
@@ -264,26 +565,60 @@ function buildSymbolLayer(symbolId, sourceId, text) {
|
|
|
264
565
|
"right",
|
|
265
566
|
"center"
|
|
266
567
|
] : "center";
|
|
568
|
+
const textJustify = text.labelProperty ? [
|
|
569
|
+
"case",
|
|
570
|
+
[
|
|
571
|
+
"==",
|
|
572
|
+
["get", "textJustify"],
|
|
573
|
+
"left"
|
|
574
|
+
],
|
|
575
|
+
"left",
|
|
576
|
+
[
|
|
577
|
+
"==",
|
|
578
|
+
["get", "textJustify"],
|
|
579
|
+
"right"
|
|
580
|
+
],
|
|
581
|
+
"right",
|
|
582
|
+
"center"
|
|
583
|
+
] : "center";
|
|
267
584
|
return {
|
|
268
585
|
id: symbolId,
|
|
269
586
|
type: "symbol",
|
|
270
587
|
source: sourceId,
|
|
271
588
|
layout: {
|
|
272
|
-
"text-field": text
|
|
273
|
-
"text-font":
|
|
589
|
+
"text-field": buildTextFieldExpression(text),
|
|
590
|
+
"text-font": text.labelProperty ? [
|
|
591
|
+
"case",
|
|
592
|
+
[
|
|
593
|
+
"==",
|
|
594
|
+
["get", "textStyle"],
|
|
595
|
+
"italic"
|
|
596
|
+
],
|
|
597
|
+
["literal", ["Open Sans Italic"]],
|
|
598
|
+
[
|
|
599
|
+
"==",
|
|
600
|
+
["get", "textStyle"],
|
|
601
|
+
"light"
|
|
602
|
+
],
|
|
603
|
+
["literal", ["Open Sans Light"]],
|
|
604
|
+
["literal", ["Open Sans Regular"]]
|
|
605
|
+
] : ["Open Sans Regular"],
|
|
274
606
|
"text-size": buildTextSizeExpression(text),
|
|
275
607
|
"text-rotate": textRotate,
|
|
276
608
|
"text-anchor": textAnchor,
|
|
609
|
+
"text-justify": textJustify,
|
|
610
|
+
"text-max-width": 9999,
|
|
277
611
|
"text-rotation-alignment": "map",
|
|
278
612
|
"text-pitch-alignment": "map",
|
|
279
|
-
"text-allow-overlap": true
|
|
613
|
+
"text-allow-overlap": true,
|
|
614
|
+
"symbol-z-order": "source"
|
|
280
615
|
},
|
|
281
616
|
paint: {
|
|
282
617
|
"text-color": textColor,
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
618
|
+
"text-halo-color": buildTextHaloColorExpression(text),
|
|
619
|
+
"text-halo-width": buildTextHaloWidthExpression(text),
|
|
620
|
+
"text-halo-blur": buildTextHaloBlurExpression(text),
|
|
621
|
+
"text-opacity": TEXT_OPACITY_EXPRESSION
|
|
287
622
|
}
|
|
288
623
|
};
|
|
289
624
|
}
|
|
@@ -305,14 +640,79 @@ function buildFillPaint(style) {
|
|
|
305
640
|
"fill-opacity": 1
|
|
306
641
|
};
|
|
307
642
|
}
|
|
643
|
+
/**
|
|
644
|
+
* `properties.style.fillPattern` read as a GL expression, defaulting to
|
|
645
|
+
* `"solid"`. `fillPattern` is a per-feature-only field (ADR-0007) — unlike
|
|
646
|
+
* `fillColor`/`strokeColor` it has no layer-level `StyleOptions` default, so
|
|
647
|
+
* this expression takes no `style` argument.
|
|
648
|
+
*/
|
|
649
|
+
function fillPatternField() {
|
|
650
|
+
return [
|
|
651
|
+
"coalesce",
|
|
652
|
+
[
|
|
653
|
+
"get",
|
|
654
|
+
"fillPattern",
|
|
655
|
+
[
|
|
656
|
+
"coalesce",
|
|
657
|
+
["get", "style"],
|
|
658
|
+
["literal", {}]
|
|
659
|
+
]
|
|
660
|
+
],
|
|
661
|
+
"solid"
|
|
662
|
+
];
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Filter for one of the two mutually-exclusive Polygon fill layers, keyed on
|
|
666
|
+
* whether the feature's resolved `fillPattern` is a hatch pattern. The solid
|
|
667
|
+
* `-fill` layer takes `hatched: false` (every Polygon except hatched ones);
|
|
668
|
+
* the `-fill-pattern` layer takes `hatched: true`. Split because MapLibre's
|
|
669
|
+
* `fill-color` and `fill-pattern` paints cannot coexist on one fill layer.
|
|
670
|
+
*/
|
|
671
|
+
function buildFillFilter(hatched) {
|
|
672
|
+
const isHatched = [
|
|
673
|
+
"in",
|
|
674
|
+
fillPatternField(),
|
|
675
|
+
["literal", [...HATCH_PATTERNS]]
|
|
676
|
+
];
|
|
677
|
+
return [
|
|
678
|
+
"all",
|
|
679
|
+
[
|
|
680
|
+
"==",
|
|
681
|
+
["geometry-type"],
|
|
682
|
+
"Polygon"
|
|
683
|
+
],
|
|
684
|
+
hatched ? isHatched : ["!", isHatched]
|
|
685
|
+
];
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Paint for the hatch-pattern fill layer. The image id is baked per-feature
|
|
689
|
+
* into `properties._fillPatternImageId` (see `promoteFeature`), so the paint
|
|
690
|
+
* is a plain `["get", ...]` rather than re-deriving the id as a GL expression,
|
|
691
|
+
* keeping `fillPatternImageId` the single source of truth for the id string.
|
|
692
|
+
*/
|
|
693
|
+
const FILL_PATTERN_PAINT = {
|
|
694
|
+
"fill-pattern": ["get", FILL_PATTERN_IMAGE_ID_PROPERTY],
|
|
695
|
+
"fill-opacity": 1
|
|
696
|
+
};
|
|
697
|
+
const SOLID_LINE_DASH = [1, 0];
|
|
308
698
|
function buildLinePaint(style) {
|
|
309
|
-
const colorDefault = style?.strokeColor ??
|
|
310
|
-
const widthDefault = style?.strokeWidth ??
|
|
699
|
+
const colorDefault = style?.strokeColor ?? DEFAULT_LINE_COLOR;
|
|
700
|
+
const widthDefault = style?.strokeWidth ?? DEFAULT_LINE_WIDTH;
|
|
701
|
+
const dashDefault = style?.strokeDash?.length ? [...style.strokeDash] : SOLID_LINE_DASH;
|
|
311
702
|
const styleObj = [
|
|
312
703
|
"coalesce",
|
|
313
704
|
["get", "style"],
|
|
314
705
|
["literal", {}]
|
|
315
706
|
];
|
|
707
|
+
const dashExpr = [
|
|
708
|
+
"coalesce",
|
|
709
|
+
[
|
|
710
|
+
"get",
|
|
711
|
+
"strokeDash",
|
|
712
|
+
styleObj
|
|
713
|
+
],
|
|
714
|
+
["literal", dashDefault]
|
|
715
|
+
];
|
|
316
716
|
return {
|
|
317
717
|
"line-color": [
|
|
318
718
|
"coalesce",
|
|
@@ -332,9 +732,54 @@ function buildLinePaint(style) {
|
|
|
332
732
|
],
|
|
333
733
|
widthDefault
|
|
334
734
|
],
|
|
335
|
-
|
|
735
|
+
"line-dasharray": [
|
|
736
|
+
"case",
|
|
737
|
+
[
|
|
738
|
+
">",
|
|
739
|
+
["length", dashExpr],
|
|
740
|
+
0
|
|
741
|
+
],
|
|
742
|
+
dashExpr,
|
|
743
|
+
["literal", SOLID_LINE_DASH]
|
|
744
|
+
]
|
|
336
745
|
};
|
|
337
746
|
}
|
|
747
|
+
function geometryIncludes(feature, geometryTypes) {
|
|
748
|
+
const geometry = feature.geometry;
|
|
749
|
+
if (!geometry) return false;
|
|
750
|
+
if (geometry.type === "GeometryCollection") return geometry.geometries.some((candidate) => geometryTypes.has(candidate.type));
|
|
751
|
+
return geometryTypes.has(geometry.type);
|
|
752
|
+
}
|
|
753
|
+
const POLYGON_GEOMETRIES = new Set(["Polygon", "MultiPolygon"]);
|
|
754
|
+
const LINE_GEOMETRIES = new Set([
|
|
755
|
+
"LineString",
|
|
756
|
+
"MultiLineString",
|
|
757
|
+
"Polygon",
|
|
758
|
+
"MultiPolygon"
|
|
759
|
+
]);
|
|
760
|
+
const POINT_GEOMETRIES = new Set(["Point", "MultiPoint"]);
|
|
761
|
+
function featureStyle(feature) {
|
|
762
|
+
const style = feature.properties?.style;
|
|
763
|
+
return style && typeof style === "object" ? style : {};
|
|
764
|
+
}
|
|
765
|
+
function groupPaintChannels(features, style) {
|
|
766
|
+
const channels = /* @__PURE__ */ new Set();
|
|
767
|
+
for (const feature of features) {
|
|
768
|
+
if (geometryIncludes(feature, POLYGON_GEOMETRIES)) {
|
|
769
|
+
const fillPattern = featureStyle(feature).fillPattern;
|
|
770
|
+
if (featureStyle(feature).fillColor !== void 0 || style?.fillColor !== void 0 || isHatchPattern(fillPattern)) channels.add(isHatchPattern(fillPattern) ? "fill-pattern" : "fill");
|
|
771
|
+
}
|
|
772
|
+
if (geometryIncludes(feature, LINE_GEOMETRIES)) channels.add("line");
|
|
773
|
+
if (!geometryIncludes(feature, POINT_GEOMETRIES)) continue;
|
|
774
|
+
const properties = feature.properties ?? {};
|
|
775
|
+
const hasIcon = properties.iconId != null;
|
|
776
|
+
const hasText = style?.text !== void 0 && (style.text.labelProperty ? properties[style.text.labelProperty] != null : style.text.text !== void 0);
|
|
777
|
+
if (hasIcon) channels.add("icon");
|
|
778
|
+
if (hasText) channels.add("text");
|
|
779
|
+
if (!hasIcon && !hasText) channels.add("circle");
|
|
780
|
+
}
|
|
781
|
+
return channels;
|
|
782
|
+
}
|
|
338
783
|
var MapLibreAdapter = class extends BaseMapAdapter {
|
|
339
784
|
map;
|
|
340
785
|
viewChangeMode;
|
|
@@ -361,14 +806,19 @@ var MapLibreAdapter = class extends BaseMapAdapter {
|
|
|
361
806
|
id: fillId,
|
|
362
807
|
type: "fill",
|
|
363
808
|
source: sourceId,
|
|
364
|
-
filter:
|
|
365
|
-
"==",
|
|
366
|
-
"$type",
|
|
367
|
-
"Polygon"
|
|
368
|
-
],
|
|
809
|
+
filter: buildFillFilter(false),
|
|
369
810
|
paint: buildFillPaint(style)
|
|
370
811
|
});
|
|
371
812
|
mlLayerIds.push(fillId);
|
|
813
|
+
const fillPatternId = `${layerId}-fill-pattern`;
|
|
814
|
+
this.map.addLayer({
|
|
815
|
+
id: fillPatternId,
|
|
816
|
+
type: "fill",
|
|
817
|
+
source: sourceId,
|
|
818
|
+
filter: buildFillFilter(true),
|
|
819
|
+
paint: FILL_PATTERN_PAINT
|
|
820
|
+
});
|
|
821
|
+
mlLayerIds.push(fillPatternId);
|
|
372
822
|
const lineId = `${layerId}-line`;
|
|
373
823
|
this.map.addLayer({
|
|
374
824
|
id: lineId,
|
|
@@ -414,29 +864,228 @@ var MapLibreAdapter = class extends BaseMapAdapter {
|
|
|
414
864
|
return {
|
|
415
865
|
sourceId,
|
|
416
866
|
mlLayerIds,
|
|
867
|
+
groupBundles: /* @__PURE__ */ new Map(),
|
|
868
|
+
groupOrder: [],
|
|
869
|
+
nextGroupBundleId: 0,
|
|
417
870
|
style
|
|
418
871
|
};
|
|
419
872
|
}
|
|
420
873
|
destroyLayer(handle) {
|
|
874
|
+
this.clearGroupBundles(handle);
|
|
421
875
|
for (const mlId of handle.mlLayerIds) if (this.map.getLayer(mlId)) this.map.removeLayer(mlId);
|
|
422
876
|
if (this.map.getSource(handle.sourceId)) this.map.removeSource(handle.sourceId);
|
|
423
877
|
}
|
|
424
|
-
|
|
878
|
+
applyFeatureGroups(layerId, groups) {
|
|
425
879
|
const handle = this.layers.get(layerId);
|
|
426
880
|
if (!handle) return;
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
881
|
+
const desiredGroupIds = new Set(groups.map((group) => group.id));
|
|
882
|
+
const previousGroupOrder = handle.groupOrder.filter((groupId) => desiredGroupIds.has(groupId));
|
|
883
|
+
const bundlesNeedingOrder = /* @__PURE__ */ new Set();
|
|
884
|
+
for (const [groupId, bundle] of handle.groupBundles) {
|
|
885
|
+
if (desiredGroupIds.has(groupId)) continue;
|
|
886
|
+
this.destroyGroupBundle(bundle);
|
|
887
|
+
handle.groupBundles.delete(groupId);
|
|
888
|
+
}
|
|
889
|
+
for (const group of groups) {
|
|
890
|
+
let bundle = handle.groupBundles.get(group.id);
|
|
891
|
+
const isNewBundle = bundle === void 0;
|
|
892
|
+
if (!bundle) {
|
|
893
|
+
bundle = this.createGroupBundle(layerId, handle);
|
|
894
|
+
handle.groupBundles.set(group.id, bundle);
|
|
434
895
|
}
|
|
435
|
-
|
|
436
|
-
|
|
896
|
+
let contentChanged;
|
|
897
|
+
if (!isNewBundle && bundle.revision !== void 0 && group.revision !== void 0) {
|
|
898
|
+
contentChanged = bundle.revision !== group.revision;
|
|
899
|
+
if (contentChanged) bundle.featureSignature = void 0;
|
|
900
|
+
} else {
|
|
901
|
+
const featureSignature = JSON.stringify(group.features);
|
|
902
|
+
contentChanged = isNewBundle || bundle.featureSignature !== featureSignature;
|
|
903
|
+
bundle.featureSignature = featureSignature;
|
|
904
|
+
}
|
|
905
|
+
const requiredIconIds = this.requiredIconIds(group);
|
|
906
|
+
const resourcesChanged = bundle.requiredIconIds.size !== requiredIconIds.size || [...bundle.requiredIconIds].some((id) => !requiredIconIds.has(id));
|
|
907
|
+
bundle.features = group.features;
|
|
908
|
+
bundle.revision = group.revision;
|
|
909
|
+
bundle.requiredIconIds = requiredIconIds;
|
|
910
|
+
if (this.reconcileGroupChannels(handle, bundle, groupPaintChannels(group.features, handle.style))) bundlesNeedingOrder.add(group.id);
|
|
911
|
+
if (contentChanged || resourcesChanged) this.updateGroupBundleSource(bundle);
|
|
912
|
+
}
|
|
913
|
+
handle.groupOrder = groups.map((group) => group.id);
|
|
914
|
+
this.orderGroupBundles(handle, previousGroupOrder, handle.groupOrder, bundlesNeedingOrder);
|
|
915
|
+
}
|
|
916
|
+
createGroupBundle(layerId, handle) {
|
|
917
|
+
const sourceId = `${layerId}-group-${++handle.nextGroupBundleId}-src`;
|
|
918
|
+
this.map.addSource(sourceId, {
|
|
919
|
+
type: "geojson",
|
|
920
|
+
data: emptyFC(),
|
|
921
|
+
promoteId: "_fid"
|
|
922
|
+
});
|
|
923
|
+
return {
|
|
924
|
+
sourceId,
|
|
925
|
+
layerIds: /* @__PURE__ */ new Map(),
|
|
926
|
+
features: [],
|
|
927
|
+
sourceFeatures: [],
|
|
928
|
+
sourceInitialized: false,
|
|
929
|
+
featureSignature: "[]",
|
|
930
|
+
requiredIconIds: /* @__PURE__ */ new Set()
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
destroyGroupBundle(bundle) {
|
|
934
|
+
for (const layerId of bundle.layerIds.values()) if (this.map.getLayer(layerId)) this.map.removeLayer(layerId);
|
|
935
|
+
if (this.map.getSource(bundle.sourceId)) this.map.removeSource(bundle.sourceId);
|
|
936
|
+
}
|
|
937
|
+
clearGroupBundles(handle) {
|
|
938
|
+
for (const bundle of handle.groupBundles.values()) this.destroyGroupBundle(bundle);
|
|
939
|
+
handle.groupBundles.clear();
|
|
940
|
+
handle.groupOrder = [];
|
|
941
|
+
}
|
|
942
|
+
requiredIconIds(group) {
|
|
943
|
+
const referenced = new Set(group.features.flatMap((feature) => {
|
|
944
|
+
const iconId = feature.properties?.iconId;
|
|
945
|
+
return typeof iconId === "string" ? [iconId] : [];
|
|
946
|
+
}));
|
|
947
|
+
return new Set(group.iconResources.map((resource) => resource.id).filter((resourceId) => referenced.has(resourceId)));
|
|
948
|
+
}
|
|
949
|
+
reconcileGroupChannels(handle, bundle, desiredChannels) {
|
|
950
|
+
let changed = false;
|
|
951
|
+
for (const channel of PAINT_CHANNELS) {
|
|
952
|
+
const layerId = bundle.layerIds.get(channel);
|
|
953
|
+
if (layerId && !desiredChannels.has(channel)) {
|
|
954
|
+
if (this.map.getLayer(layerId)) this.map.removeLayer(layerId);
|
|
955
|
+
bundle.layerIds.delete(channel);
|
|
956
|
+
changed = true;
|
|
957
|
+
} else if (!layerId && desiredChannels.has(channel)) {
|
|
958
|
+
const nextLayerId = `${bundle.sourceId}-${channel}`;
|
|
959
|
+
this.map.addLayer(this.buildGroupLayer(nextLayerId, bundle.sourceId, channel, handle.style), handle.mlLayerIds[0]);
|
|
960
|
+
bundle.layerIds.set(channel, nextLayerId);
|
|
961
|
+
changed = true;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
return changed;
|
|
965
|
+
}
|
|
966
|
+
buildGroupLayer(id, sourceId, channel, style) {
|
|
967
|
+
switch (channel) {
|
|
968
|
+
case "fill": return {
|
|
969
|
+
id,
|
|
970
|
+
type: "fill",
|
|
971
|
+
source: sourceId,
|
|
972
|
+
filter: buildFillFilter(false),
|
|
973
|
+
paint: buildFillPaint(style)
|
|
974
|
+
};
|
|
975
|
+
case "fill-pattern": return {
|
|
976
|
+
id,
|
|
977
|
+
type: "fill",
|
|
978
|
+
source: sourceId,
|
|
979
|
+
filter: buildFillFilter(true),
|
|
980
|
+
paint: FILL_PATTERN_PAINT
|
|
981
|
+
};
|
|
982
|
+
case "line": return {
|
|
983
|
+
id,
|
|
984
|
+
type: "line",
|
|
985
|
+
source: sourceId,
|
|
986
|
+
filter: [
|
|
987
|
+
"any",
|
|
988
|
+
[
|
|
989
|
+
"==",
|
|
990
|
+
"$type",
|
|
991
|
+
"LineString"
|
|
992
|
+
],
|
|
993
|
+
[
|
|
994
|
+
"==",
|
|
995
|
+
"$type",
|
|
996
|
+
"Polygon"
|
|
997
|
+
]
|
|
998
|
+
],
|
|
999
|
+
paint: buildLinePaint(style),
|
|
1000
|
+
layout: {
|
|
1001
|
+
"line-cap": "round",
|
|
1002
|
+
"line-join": "round"
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
1005
|
+
case "circle": return {
|
|
1006
|
+
id,
|
|
1007
|
+
type: "circle",
|
|
1008
|
+
source: sourceId,
|
|
1009
|
+
filter: buildCirclePointFilter(style),
|
|
1010
|
+
paint: buildCirclePaint(style)
|
|
1011
|
+
};
|
|
1012
|
+
case "icon": return buildIconLayer(id, sourceId);
|
|
1013
|
+
case "text":
|
|
1014
|
+
if (!style?.text) throw new Error("Cannot create a MapLibre text channel without text style");
|
|
1015
|
+
return buildSymbolLayer(id, sourceId, style.text);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
updateGroupBundleSource(bundle) {
|
|
1019
|
+
const source = this.map.getSource(bundle.sourceId);
|
|
1020
|
+
if (!source) return;
|
|
1021
|
+
const features = [...bundle.requiredIconIds].every((id) => this.map.hasImage(id)) ? bundle.features.map((feature) => this.promoteFeature(feature)) : [];
|
|
1022
|
+
const result = bundle.sourceInitialized && typeof source.updateData === "function" ? source.updateData(buildGeoJSONSourceDiff(bundle.sourceFeatures, features), true) : source.setData({
|
|
437
1023
|
type: "FeatureCollection",
|
|
438
1024
|
features
|
|
439
|
-
});
|
|
1025
|
+
}, true);
|
|
1026
|
+
bundle.sourceFeatures = features;
|
|
1027
|
+
bundle.sourceInitialized = true;
|
|
1028
|
+
if (isPromiseLike(result)) Promise.resolve(result).catch(() => {});
|
|
1029
|
+
}
|
|
1030
|
+
orderGroupBundles(handle, previousGroupIds, desiredGroupIds, forcedGroupIds) {
|
|
1031
|
+
let beforeId = handle.mlLayerIds[0];
|
|
1032
|
+
if (!beforeId) return;
|
|
1033
|
+
const stableGroupIds = sameOrder(previousGroupIds, desiredGroupIds) ? null : longestStableGroupIds(previousGroupIds, desiredGroupIds);
|
|
1034
|
+
for (let index = desiredGroupIds.length - 1; index >= 0; index--) {
|
|
1035
|
+
const groupId = desiredGroupIds[index];
|
|
1036
|
+
const bundle = handle.groupBundles.get(groupId);
|
|
1037
|
+
if (!bundle) continue;
|
|
1038
|
+
if (forcedGroupIds.has(groupId) || stableGroupIds !== null && !stableGroupIds.has(groupId)) for (const channel of PAINT_CHANNELS) {
|
|
1039
|
+
const layerId = bundle.layerIds.get(channel);
|
|
1040
|
+
if (layerId && this.map.getLayer(layerId)) this.map.moveLayer(layerId, beforeId);
|
|
1041
|
+
}
|
|
1042
|
+
beforeId = firstBundleLayerId(bundle) ?? beforeId;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
nativeLayerIds(handle) {
|
|
1046
|
+
return [...handle.mlLayerIds, ...[...handle.groupBundles.values()].flatMap((bundle) => [...bundle.layerIds.values()])];
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
1049
|
+
* Promote a source feature for MapLibre: stamp `_fid` (so data-driven paint
|
|
1050
|
+
* and picks can key on the caller's feature id) and, for a hatch-patterned
|
|
1051
|
+
* Polygon, `_fillPatternImageId` (registering the tile image on demand). At
|
|
1052
|
+
* most one object spread — a feature needing neither passes through
|
|
1053
|
+
* untouched.
|
|
1054
|
+
*/
|
|
1055
|
+
promoteFeature(feature) {
|
|
1056
|
+
const extra = {};
|
|
1057
|
+
if (feature.id !== void 0) extra._fid = feature.id;
|
|
1058
|
+
const imageId = this.resolveFillPatternImage(feature);
|
|
1059
|
+
if (imageId !== void 0) extra[FILL_PATTERN_IMAGE_ID_PROPERTY] = imageId;
|
|
1060
|
+
if (Object.keys(extra).length === 0) return feature;
|
|
1061
|
+
return {
|
|
1062
|
+
...feature,
|
|
1063
|
+
properties: {
|
|
1064
|
+
...feature.properties ?? {},
|
|
1065
|
+
...extra
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Resolve a hatch-patterned Polygon's `style.fillPattern`/`fillColor` to its
|
|
1071
|
+
* hatch-tile image id, registering the image on demand. Returns `undefined`
|
|
1072
|
+
* for features without a hatch pattern, so `fillPatternImageId` stays the
|
|
1073
|
+
* single source of truth for the id baked into `_fillPatternImageId`.
|
|
1074
|
+
*/
|
|
1075
|
+
resolveFillPatternImage(feature) {
|
|
1076
|
+
const props = feature.properties;
|
|
1077
|
+
const pattern = props?.style?.fillPattern;
|
|
1078
|
+
if (!isHatchPattern(pattern)) return void 0;
|
|
1079
|
+
const color = props?.fillColor ?? props?.style?.fillColor ?? "rgba(0,0,0,0)";
|
|
1080
|
+
const imageId = fillPatternImageId(color, pattern);
|
|
1081
|
+
this.ensureFillPatternImage(imageId, color, pattern);
|
|
1082
|
+
return imageId;
|
|
1083
|
+
}
|
|
1084
|
+
/** Register a hatch-tile image once per (color, pattern) pair, keyed by `fillPatternImageId`. */
|
|
1085
|
+
ensureFillPatternImage(imageId, color, pattern) {
|
|
1086
|
+
if (this.map.hasImage(imageId)) return;
|
|
1087
|
+
const image = makeFillPatternImage(color, pattern, 2);
|
|
1088
|
+
this.map.addImage(imageId, image, { pixelRatio: 2 });
|
|
440
1089
|
}
|
|
441
1090
|
applyIconResources(resources) {
|
|
442
1091
|
if (typeof Image === "undefined") return;
|
|
@@ -448,6 +1097,7 @@ var MapLibreAdapter = class extends BaseMapAdapter {
|
|
|
448
1097
|
if (!this.pendingIconImages.has(resource.id)) return;
|
|
449
1098
|
this.pendingIconImages.delete(resource.id);
|
|
450
1099
|
if (!this.map.hasImage(resource.id)) this.map.addImage(resource.id, image);
|
|
1100
|
+
for (const handle of this.layers.values()) for (const bundle of handle.groupBundles.values()) if (bundle.requiredIconIds.has(resource.id)) this.updateGroupBundleSource(bundle);
|
|
451
1101
|
};
|
|
452
1102
|
image.onerror = () => this.pendingIconImages.delete(resource.id);
|
|
453
1103
|
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(centeredSvg(resource))}`;
|
|
@@ -605,40 +1255,75 @@ var MapLibreAdapter = class extends BaseMapAdapter {
|
|
|
605
1255
|
setLayerStyle(layerId, style) {
|
|
606
1256
|
const handle = this.layers.get(layerId);
|
|
607
1257
|
if (!handle) return;
|
|
608
|
-
|
|
1258
|
+
const resolvedStyle = {
|
|
1259
|
+
...handle.style,
|
|
1260
|
+
...style,
|
|
1261
|
+
...style.text ? { text: {
|
|
1262
|
+
...handle.style?.text,
|
|
1263
|
+
...style.text
|
|
1264
|
+
} } : {}
|
|
1265
|
+
};
|
|
1266
|
+
handle.style = resolvedStyle;
|
|
609
1267
|
for (const mlId of handle.mlLayerIds) {
|
|
610
|
-
const
|
|
611
|
-
if (
|
|
612
|
-
const type = layer.type;
|
|
613
|
-
if (type === "fill") this.map.setPaintProperty(mlId, "fill-color", buildFillPaint(style)["fill-color"]);
|
|
614
|
-
else if (type === "line") {
|
|
615
|
-
if (style.strokeColor !== void 0 || style.strokeWidth !== void 0) {
|
|
616
|
-
const paint = buildLinePaint(style);
|
|
617
|
-
if (style.strokeColor !== void 0) this.map.setPaintProperty(mlId, "line-color", paint["line-color"]);
|
|
618
|
-
if (style.strokeWidth !== void 0) this.map.setPaintProperty(mlId, "line-width", paint["line-width"]);
|
|
619
|
-
}
|
|
620
|
-
if (style.strokeDash) this.map.setPaintProperty(mlId, "line-dasharray", style.strokeDash);
|
|
621
|
-
} else if (type === "circle") {
|
|
622
|
-
const paint = buildCirclePaint(style);
|
|
623
|
-
this.map.setPaintProperty(mlId, "circle-radius", paint["circle-radius"]);
|
|
624
|
-
this.map.setPaintProperty(mlId, "circle-color", paint["circle-color"]);
|
|
625
|
-
this.map.setPaintProperty(mlId, "circle-stroke-color", paint["circle-stroke-color"]);
|
|
626
|
-
this.map.setPaintProperty(mlId, "circle-stroke-width", paint["circle-stroke-width"]);
|
|
627
|
-
this.map.setFilter(mlId, buildCirclePointFilter(style));
|
|
628
|
-
} else if (type === "symbol" && style.text) {
|
|
629
|
-
if (style.text.text) this.map.setLayoutProperty(mlId, "text-field", style.text.text);
|
|
630
|
-
else if (style.text.labelProperty) this.map.setLayoutProperty(mlId, "text-field", ["get", style.text.labelProperty]);
|
|
631
|
-
if (style.text.fillColor) this.map.setPaintProperty(mlId, "text-color", style.text.fillColor);
|
|
632
|
-
if (style.text.strokeColor) this.map.setPaintProperty(mlId, "text-halo-color", style.text.strokeColor);
|
|
633
|
-
if (style.text.strokeWidth !== void 0) this.map.setPaintProperty(mlId, "text-halo-width", style.text.strokeWidth);
|
|
634
|
-
}
|
|
1268
|
+
const channel = this.legacyPaintChannel(mlId);
|
|
1269
|
+
if (channel) this.applyChannelStyle(mlId, channel, style, resolvedStyle);
|
|
635
1270
|
}
|
|
1271
|
+
const bundlesNeedingOrder = /* @__PURE__ */ new Set();
|
|
1272
|
+
for (const [groupId, bundle] of handle.groupBundles) if (this.reconcileGroupChannels(handle, bundle, groupPaintChannels(bundle.features, resolvedStyle))) bundlesNeedingOrder.add(groupId);
|
|
1273
|
+
this.orderGroupBundles(handle, handle.groupOrder, handle.groupOrder, bundlesNeedingOrder);
|
|
1274
|
+
for (const bundle of handle.groupBundles.values()) for (const [channel, mlId] of bundle.layerIds) this.applyChannelStyle(mlId, channel, style, resolvedStyle);
|
|
636
1275
|
if (style.text && !handle.mlLayerIds.some((id) => id.endsWith("-symbol"))) {
|
|
637
1276
|
const symbolId = `${layerId}-symbol`;
|
|
638
|
-
this.map.addLayer(buildSymbolLayer(symbolId, handle.sourceId,
|
|
1277
|
+
this.map.addLayer(buildSymbolLayer(symbolId, handle.sourceId, resolvedStyle.text));
|
|
639
1278
|
handle.mlLayerIds.push(symbolId);
|
|
640
1279
|
}
|
|
641
1280
|
}
|
|
1281
|
+
legacyPaintChannel(layerId) {
|
|
1282
|
+
if (layerId.endsWith("-fill-pattern")) return "fill-pattern";
|
|
1283
|
+
if (layerId.endsWith("-fill")) return "fill";
|
|
1284
|
+
if (layerId.endsWith("-line")) return "line";
|
|
1285
|
+
if (layerId.endsWith("-circle")) return "circle";
|
|
1286
|
+
if (layerId.endsWith("-icon")) return "icon";
|
|
1287
|
+
if (layerId.endsWith("-symbol")) return "text";
|
|
1288
|
+
}
|
|
1289
|
+
applyChannelStyle(layerId, channel, styleUpdate, resolvedStyle) {
|
|
1290
|
+
if (!this.map.getLayer(layerId)) return;
|
|
1291
|
+
switch (channel) {
|
|
1292
|
+
case "fill":
|
|
1293
|
+
if (styleUpdate.fillColor === void 0) break;
|
|
1294
|
+
this.map.setPaintProperty(layerId, "fill-color", buildFillPaint(resolvedStyle)["fill-color"]);
|
|
1295
|
+
break;
|
|
1296
|
+
case "fill-pattern": break;
|
|
1297
|
+
case "line": {
|
|
1298
|
+
const paint = buildLinePaint(resolvedStyle);
|
|
1299
|
+
if (styleUpdate.strokeColor !== void 0) this.map.setPaintProperty(layerId, "line-color", paint["line-color"]);
|
|
1300
|
+
if (styleUpdate.strokeWidth !== void 0) this.map.setPaintProperty(layerId, "line-width", paint["line-width"]);
|
|
1301
|
+
if (styleUpdate.strokeDash !== void 0) this.map.setPaintProperty(layerId, "line-dasharray", paint["line-dasharray"]);
|
|
1302
|
+
break;
|
|
1303
|
+
}
|
|
1304
|
+
case "circle": {
|
|
1305
|
+
if (!(styleUpdate.pointRadius !== void 0 || styleUpdate.pointFillColor !== void 0 || styleUpdate.fillColor !== void 0 || styleUpdate.strokeColor !== void 0 || styleUpdate.strokeWidth !== void 0 || styleUpdate.text !== void 0)) break;
|
|
1306
|
+
const paint = buildCirclePaint(resolvedStyle);
|
|
1307
|
+
this.map.setPaintProperty(layerId, "circle-radius", paint["circle-radius"]);
|
|
1308
|
+
this.map.setPaintProperty(layerId, "circle-color", paint["circle-color"]);
|
|
1309
|
+
this.map.setPaintProperty(layerId, "circle-stroke-color", paint["circle-stroke-color"]);
|
|
1310
|
+
this.map.setPaintProperty(layerId, "circle-stroke-width", paint["circle-stroke-width"]);
|
|
1311
|
+
this.map.setFilter(layerId, buildCirclePointFilter(resolvedStyle));
|
|
1312
|
+
break;
|
|
1313
|
+
}
|
|
1314
|
+
case "icon": break;
|
|
1315
|
+
case "text":
|
|
1316
|
+
if (!styleUpdate.text || !resolvedStyle.text) break;
|
|
1317
|
+
if (styleUpdate.text.text || styleUpdate.text.labelProperty) this.map.setLayoutProperty(layerId, "text-field", buildTextFieldExpression(resolvedStyle.text));
|
|
1318
|
+
if (styleUpdate.text.fillColor) this.map.setPaintProperty(layerId, "text-color", resolvedStyle.text.fillColor);
|
|
1319
|
+
if (styleUpdate.text.strokeColor) this.map.setPaintProperty(layerId, "text-halo-color", buildTextHaloColorExpression(resolvedStyle.text));
|
|
1320
|
+
if (styleUpdate.text.strokeWidth !== void 0) {
|
|
1321
|
+
this.map.setPaintProperty(layerId, "text-halo-width", buildTextHaloWidthExpression(resolvedStyle.text));
|
|
1322
|
+
this.map.setPaintProperty(layerId, "text-halo-blur", buildTextHaloBlurExpression(resolvedStyle.text));
|
|
1323
|
+
}
|
|
1324
|
+
break;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
642
1327
|
toLonLat(coordinate) {
|
|
643
1328
|
return unproject(coordinate[0], coordinate[1]);
|
|
644
1329
|
}
|
|
@@ -649,6 +1334,10 @@ var MapLibreAdapter = class extends BaseMapAdapter {
|
|
|
649
1334
|
const point = this.map.project([this.toContinuousLng(position[0]), position[1]]);
|
|
650
1335
|
return [point.x, point.y];
|
|
651
1336
|
}
|
|
1337
|
+
getCoordinateFromPixel(pixel) {
|
|
1338
|
+
const coordinate = this.map.unproject(pixel);
|
|
1339
|
+
return [this.toContinuousLng(coordinate.lng), coordinate.lat];
|
|
1340
|
+
}
|
|
652
1341
|
getViewportSize() {
|
|
653
1342
|
const container = this.map.getContainer();
|
|
654
1343
|
return {
|
|
@@ -694,20 +1383,48 @@ var MapLibreAdapter = class extends BaseMapAdapter {
|
|
|
694
1383
|
const tol = pickHitTolerance(originalEvent);
|
|
695
1384
|
const { x, y } = e.point;
|
|
696
1385
|
const bbox = [[x - tol, y - tol], [x + tol, y + tol]];
|
|
697
|
-
const
|
|
698
|
-
if (features.length === 0) return;
|
|
699
|
-
const f = features[0];
|
|
700
|
-
const geojson = {
|
|
1386
|
+
const relevantFeatures = mergeNativeAndCachedPickFeatures(this.map.queryRenderedFeatures(bbox, { layers: this.nativeLayerIds(handle) }).map((feature) => ({
|
|
701
1387
|
type: "Feature",
|
|
702
|
-
id:
|
|
703
|
-
geometry:
|
|
704
|
-
properties:
|
|
705
|
-
};
|
|
706
|
-
const
|
|
1388
|
+
id: feature.id,
|
|
1389
|
+
geometry: feature.geometry,
|
|
1390
|
+
properties: feature.properties
|
|
1391
|
+
})), this.featureCache.get(layerId) ?? []);
|
|
1392
|
+
const size = this.getViewportSize();
|
|
1393
|
+
const candidate = evaluatePickCandidates(relevantFeatures.map(({ feature, nativeHit, visualOrder }) => {
|
|
1394
|
+
const iconId = feature.properties?.iconId;
|
|
1395
|
+
const icon = typeof iconId === "string" ? this.getIconResource(iconId) : void 0;
|
|
1396
|
+
const featureGroupOrder = this.getFeatureGroupOrder(layerId, feature.id);
|
|
1397
|
+
return {
|
|
1398
|
+
feature,
|
|
1399
|
+
visualOrder,
|
|
1400
|
+
nativeHit,
|
|
1401
|
+
...featureGroupOrder !== void 0 ? { featureGroupOrder } : {},
|
|
1402
|
+
...icon ? { icon } : {},
|
|
1403
|
+
layerStyle: {
|
|
1404
|
+
...handle.style,
|
|
1405
|
+
strokeColor: handle.style?.strokeColor ?? DEFAULT_LINE_COLOR,
|
|
1406
|
+
strokeWidth: handle.style?.strokeWidth ?? DEFAULT_LINE_WIDTH
|
|
1407
|
+
}
|
|
1408
|
+
};
|
|
1409
|
+
}), {
|
|
1410
|
+
pixel: [x, y],
|
|
1411
|
+
pointerType: pickPointerType(originalEvent),
|
|
1412
|
+
tolerance: tol,
|
|
1413
|
+
toPixel: (position) => this.getPixelFromCoordinate(position),
|
|
1414
|
+
viewport: {
|
|
1415
|
+
minX: 0,
|
|
1416
|
+
minY: 0,
|
|
1417
|
+
maxX: size.width,
|
|
1418
|
+
maxY: size.height
|
|
1419
|
+
},
|
|
1420
|
+
bearing: this.getBearing()
|
|
1421
|
+
});
|
|
1422
|
+
if (!candidate) return;
|
|
1423
|
+
const cmId = candidate.graphicId;
|
|
707
1424
|
if (cmId == null) return;
|
|
708
1425
|
handler({
|
|
709
1426
|
id: cmId,
|
|
710
|
-
feature:
|
|
1427
|
+
feature: candidate.feature,
|
|
711
1428
|
pixel: [x, y],
|
|
712
1429
|
coordinate: [this.toContinuousLng(e.lngLat.lng), e.lngLat.lat],
|
|
713
1430
|
originalEvent
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orbat-mapper/tactical-draw-adapter-maplibre",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "MapLibre GL JS engine adapter for @orbat-mapper/tactical-draw. Draw and edit tactical control measures (MIL-STD-2525 / APP-6) on a MapLibre map.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Orbat Mapper",
|
|
@@ -37,17 +37,18 @@
|
|
|
37
37
|
"access": "public"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@orbat-mapper/control-measures": "^0.
|
|
41
|
-
"@orbat-mapper/tactical-draw": "^0.2.
|
|
40
|
+
"@orbat-mapper/control-measures": "^0.3.0",
|
|
41
|
+
"@orbat-mapper/tactical-draw": "^0.2.1"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"maplibre-gl": ">=
|
|
44
|
+
"maplibre-gl": ">=5.8.0 <6.0.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@arethetypeswrong/core": "^0.18.3",
|
|
48
|
+
"@maplibre/maplibre-gl-style-spec": "^24.10.0",
|
|
48
49
|
"@types/geojson": "^7946.0.16",
|
|
49
50
|
"jsdom": "^27.4.0",
|
|
50
|
-
"maplibre-gl": "^5.
|
|
51
|
+
"maplibre-gl": "^5.24.0",
|
|
51
52
|
"publint": "^0.3.21",
|
|
52
53
|
"tsdown": "^0.22.2",
|
|
53
54
|
"typescript": "~5.9.3",
|