@goway.to/maps 0.0.0-stage → 0.1.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/CHANGELOG.md +24 -0
- package/LICENSE +676 -0
- package/NOTICE +25 -0
- package/README.md +59 -2
- package/app.plugin.js +19 -0
- package/dist/CameraBinding.web.d.ts +24 -0
- package/dist/CameraBinding.web.js +46 -0
- package/dist/DefaultMapMarker.d.ts +8 -0
- package/dist/DefaultMapMarker.js +30 -0
- package/dist/GeoJSONSource.d.ts +2 -0
- package/dist/GeoJSONSource.js +17 -0
- package/dist/GeoJSONSource.web.d.ts +2 -0
- package/dist/GeoJSONSource.web.js +68 -0
- package/dist/InteractionBinding.web.d.ts +7 -0
- package/dist/InteractionBinding.web.js +35 -0
- package/dist/Layer.d.ts +2 -0
- package/dist/Layer.js +21 -0
- package/dist/Layer.web.d.ts +2 -0
- package/dist/Layer.web.js +46 -0
- package/dist/LayerStyle.web.d.ts +10 -0
- package/dist/LayerStyle.web.js +45 -0
- package/dist/LightBinding.web.d.ts +5 -0
- package/dist/LightBinding.web.js +19 -0
- package/dist/MapAttribution.d.ts +27 -0
- package/dist/MapAttribution.js +149 -0
- package/dist/MapBrand.d.ts +22 -0
- package/dist/MapBrand.js +91 -0
- package/dist/MapErrorState.d.ts +9 -0
- package/dist/MapErrorState.js +58 -0
- package/dist/MapPlugin.d.ts +6 -0
- package/dist/MapPlugin.js +3 -0
- package/dist/MapPlugin.web.d.ts +2 -0
- package/dist/MapPlugin.web.js +43 -0
- package/dist/MapView.d.ts +2 -0
- package/dist/MapView.js +420 -0
- package/dist/MapView.web.d.ts +5 -0
- package/dist/MapView.web.js +413 -0
- package/dist/Marker.d.ts +13 -0
- package/dist/Marker.js +11 -0
- package/dist/Marker.web.d.ts +15 -0
- package/dist/Marker.web.js +51 -0
- package/dist/Markers.d.ts +2 -0
- package/dist/Markers.js +13 -0
- package/dist/Markers.web.d.ts +2 -0
- package/dist/Markers.web.js +13 -0
- package/dist/PluginProps.web.d.ts +7 -0
- package/dist/PluginProps.web.js +19 -0
- package/dist/RasterDEMSource.d.ts +2 -0
- package/dist/RasterDEMSource.js +7 -0
- package/dist/RasterDEMSource.web.d.ts +2 -0
- package/dist/RasterDEMSource.web.js +44 -0
- package/dist/SourceData.web.d.ts +6 -0
- package/dist/SourceData.web.js +14 -0
- package/dist/UserLocation.d.ts +2 -0
- package/dist/UserLocation.js +19 -0
- package/dist/UserLocation.web.d.ts +2 -0
- package/dist/UserLocation.web.js +21 -0
- package/dist/brand/BrandedChrome.d.ts +47 -0
- package/dist/brand/BrandedChrome.js +51 -0
- package/dist/brand/GowayLogo.d.ts +28 -0
- package/dist/brand/GowayLogo.js +50 -0
- package/dist/brand/artwork.d.ts +130 -0
- package/dist/brand/artwork.js +176 -0
- package/dist/cameraReports.web.d.ts +50 -0
- package/dist/cameraReports.web.js +117 -0
- package/dist/cameraTarget.d.ts +14 -0
- package/dist/cameraTarget.js +8 -0
- package/dist/chromeLift.d.ts +15 -0
- package/dist/chromeLift.js +15 -0
- package/dist/clusters.d.ts +57 -0
- package/dist/clusters.js +102 -0
- package/dist/commands.web.d.ts +16 -0
- package/dist/commands.web.js +158 -0
- package/dist/compactCredit.d.ts +52 -0
- package/dist/compactCredit.js +40 -0
- package/dist/creditHeight.d.ts +3 -0
- package/dist/creditHeight.js +12 -0
- package/dist/creditLabels.d.ts +30 -0
- package/dist/creditLabels.js +30 -0
- package/dist/creditRise.d.ts +28 -0
- package/dist/creditRise.js +33 -0
- package/dist/dragAxes.web.d.ts +65 -0
- package/dist/dragAxes.web.js +70 -0
- package/dist/engineContext.web.d.ts +13 -0
- package/dist/engineContext.web.js +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/index.web.d.ts +13 -0
- package/dist/index.web.js +13 -0
- package/dist/labels.d.ts +202 -0
- package/dist/labels.js +451 -0
- package/dist/loadedCamera.d.ts +64 -0
- package/dist/loadedCamera.js +56 -0
- package/dist/locationStore.d.ts +16 -0
- package/dist/locationStore.js +132 -0
- package/dist/mapRef.d.ts +4 -0
- package/dist/mapRef.js +13 -0
- package/dist/moveMarkers.d.ts +27 -0
- package/dist/moveMarkers.js +22 -0
- package/dist/nativeCameraReports.d.ts +44 -0
- package/dist/nativeCameraReports.js +73 -0
- package/dist/plugin.d.ts +63 -0
- package/dist/plugin.js +2 -0
- package/dist/queryLayers.d.ts +19 -0
- package/dist/queryLayers.js +54 -0
- package/dist/shared-exports.d.ts +17 -0
- package/dist/shared-exports.js +13 -0
- package/dist/shared.d.ts +170 -0
- package/dist/shared.js +337 -0
- package/dist/sourceContext.d.ts +2 -0
- package/dist/sourceContext.js +9 -0
- package/dist/styleUrl.d.ts +19 -0
- package/dist/styleUrl.js +22 -0
- package/dist/testing.d.ts +41 -0
- package/dist/testing.js +80 -0
- package/dist/types.d.ts +348 -0
- package/dist/types.js +1 -0
- package/dist/useMarkerSlots.d.ts +6 -0
- package/dist/useMarkerSlots.js +56 -0
- package/dist/useUserLocation.d.ts +44 -0
- package/dist/useUserLocation.js +247 -0
- package/dist/zoom.d.ts +2 -0
- package/dist/zoom.js +10 -0
- package/metro.cjs +70 -0
- package/package.json +115 -4
package/dist/labels.js
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The basemap's own labels, as GoWay-shaped things.
|
|
3
|
+
*
|
|
4
|
+
* The vector tiles have always carried the names — POIs, streets, rivers,
|
|
5
|
+
* districts, cities — and GoWay's style has always drawn them. Until this
|
|
6
|
+
* module they were PAINT: `map.on('click')` reported a coordinate and nothing
|
|
7
|
+
* ever asked the engine what was under the finger. Every name on the map was
|
|
8
|
+
* dead pixels, while GoWay drew its own chips on top of some of them.
|
|
9
|
+
*
|
|
10
|
+
* This file is where a tapped tile feature stops being the engine's and becomes
|
|
11
|
+
* GoWay's. Both engine forks query their own engine (`queryRenderedFeatures`
|
|
12
|
+
* on web, `MapRef.queryRenderedFeatures` on native), flatten each hit into a
|
|
13
|
+
* {@link QueriedLabel}, and hand the list here; what comes back is a
|
|
14
|
+
* {@link MapLabelFeature}, the only shape an app ever sees. The flattening
|
|
15
|
+
* happens in the forks and the RANKING happens here: the rule that decides
|
|
16
|
+
* which of five overlapping labels a tap meant must be identical on iOS and on
|
|
17
|
+
* the web, and it is testable only if it is a pure function.
|
|
18
|
+
*
|
|
19
|
+
* ## Every text on the map, not just the shop pins
|
|
20
|
+
*
|
|
21
|
+
* {@link LABEL_SOURCE_LAYERS} covers all six source-layers GoWay's style draws
|
|
22
|
+
* a name from: POIs, places (city → neighbourhood), parks, aerodromes, peaks,
|
|
23
|
+
* street names, water bodies and waterways. A tappable shop beside a dead
|
|
24
|
+
* street name is the inconsistency a user notices first.
|
|
25
|
+
*
|
|
26
|
+
* ## What was measured, rather than assumed
|
|
27
|
+
*
|
|
28
|
+
* Driving headless Chromium against the real GoWay style and real OpenFreeMap
|
|
29
|
+
* tiles over Barcelona:
|
|
30
|
+
*
|
|
31
|
+
* - A query at the EXACT pixel of a POI's anchor returns the dot and nothing
|
|
32
|
+
* else; six pixels away it returns nothing at all. A label glyph is a few
|
|
33
|
+
* pixels tall and `text-anchor: top` puts the text BELOW the anchor, so the
|
|
34
|
+
* thing a user aims at and the point the engine indexes are 12–20 px apart.
|
|
35
|
+
* That is why {@link LABEL_HIT_PAD_PX} exists and why it is not 2.
|
|
36
|
+
* - A padded box of 10 px catches both the dot and its label across the whole
|
|
37
|
+
* band; a tap on empty ground still returns `[]`, so a generous pad does not
|
|
38
|
+
* manufacture hits.
|
|
39
|
+
* - `queryRenderedFeatures` on the symbol layers returned 16 labels where the
|
|
40
|
+
* loaded source held 3185 POI features in the same view. It answers with
|
|
41
|
+
* what the collision system actually PLACED, not with what the tiles
|
|
42
|
+
* contain.
|
|
43
|
+
*/
|
|
44
|
+
import { OPENMAPTILES_SOURCE_LAYERS as SL } from '@goway.to/sdk';
|
|
45
|
+
import { isDrawableCoordinate } from './shared';
|
|
46
|
+
/**
|
|
47
|
+
* How far around the tap the engine is asked to look, in px (web) / dp (native).
|
|
48
|
+
*
|
|
49
|
+
* The measurement above says 10 px is the minimum that reaches both a POI's dot
|
|
50
|
+
* and its label. 18 is that plus finger slop, and a generous pad costs nothing
|
|
51
|
+
* because {@link pickLabelFeature} resolves a crowded box by distance — the
|
|
52
|
+
* nearest label still wins, so widening the net changes what a sloppy tap hits
|
|
53
|
+
* and never what an accurate one hits.
|
|
54
|
+
*
|
|
55
|
+
* One number for mouse and touch on purpose. Sniffing the pointer type is
|
|
56
|
+
* unreliable (a browser-synthesised click from a touch is an ordinary
|
|
57
|
+
* `MouseEvent`), and a click 14 px from a label is a click that meant the
|
|
58
|
+
* label: a tap on genuinely empty ground was measured to return nothing at any
|
|
59
|
+
* pad, so the failure this could cause — "I clicked nothing and got something"
|
|
60
|
+
* — has nothing to be caused by.
|
|
61
|
+
*/
|
|
62
|
+
export const LABEL_HIT_PAD_PX = 18;
|
|
63
|
+
/**
|
|
64
|
+
* Source-layers a tap may resolve against, in the OpenMapTiles v3 vocabulary.
|
|
65
|
+
*
|
|
66
|
+
* Read from GoWay's tile schema (`@goway.to/sdk`) rather than spelled here: a
|
|
67
|
+
* wrong source-layer name is the failure mode that logs nothing and throws
|
|
68
|
+
* nothing, and the schema is the one place those names are recorded.
|
|
69
|
+
*
|
|
70
|
+
* This is every layer GoWay's style puts a NAME on. `transportation` is absent
|
|
71
|
+
* although the style draws a symbol from it — that symbol is `road-oneway`, an
|
|
72
|
+
* arrow, and an arrow is not a label. `building` and `housenumber` are absent
|
|
73
|
+
* because a house number is an address fragment, not a thing with a name.
|
|
74
|
+
*/
|
|
75
|
+
export const LABEL_SOURCE_LAYERS = [
|
|
76
|
+
SL.poi,
|
|
77
|
+
SL.place,
|
|
78
|
+
SL.park,
|
|
79
|
+
SL.aerodromeLabel,
|
|
80
|
+
SL.mountainPeak,
|
|
81
|
+
SL.transportationName,
|
|
82
|
+
SL.waterName,
|
|
83
|
+
SL.waterway,
|
|
84
|
+
];
|
|
85
|
+
const LABEL_SOURCE_LAYER_SET = new Set(LABEL_SOURCE_LAYERS);
|
|
86
|
+
/**
|
|
87
|
+
* The name the basemap is DRAWING, or `null`.
|
|
88
|
+
*
|
|
89
|
+
* Exactly `coalesce(name:latin, name)` — the `text-field` expression of
|
|
90
|
+
* GoWay's styles. Not `name_en`, not `name_int`: a label that says
|
|
91
|
+
* one thing on screen and another in the sheet is a worse bug than no sheet,
|
|
92
|
+
* and the user tapped what they could read.
|
|
93
|
+
*/
|
|
94
|
+
export function labelNameOf(properties) {
|
|
95
|
+
const latin = properties['name:latin'];
|
|
96
|
+
if (typeof latin === 'string' && latin.trim())
|
|
97
|
+
return latin.trim();
|
|
98
|
+
const name = properties.name;
|
|
99
|
+
if (typeof name === 'string' && name.trim())
|
|
100
|
+
return name.trim();
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* What kind of named thing this is, decided by SOURCE-LAYER rather than by
|
|
105
|
+
* guesswork over the name.
|
|
106
|
+
*
|
|
107
|
+
* Four kinds, because four is what the product acts on differently — see
|
|
108
|
+
* `MapLabelKind` in `types.ts` for what each one means and what a tap on it
|
|
109
|
+
* does.
|
|
110
|
+
*/
|
|
111
|
+
export function labelKindOf(sourceLayer) {
|
|
112
|
+
switch (sourceLayer) {
|
|
113
|
+
case SL.poi:
|
|
114
|
+
case SL.aerodromeLabel:
|
|
115
|
+
return 'poi';
|
|
116
|
+
case SL.place:
|
|
117
|
+
case SL.park:
|
|
118
|
+
case SL.mountainPeak:
|
|
119
|
+
return 'area';
|
|
120
|
+
case SL.transportationName:
|
|
121
|
+
return 'road';
|
|
122
|
+
case SL.waterName:
|
|
123
|
+
case SL.waterway:
|
|
124
|
+
return 'water';
|
|
125
|
+
default:
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Whether a source-layer can answer a tap at all. */
|
|
130
|
+
export function isLabelSourceLayer(sourceLayer) {
|
|
131
|
+
return LABEL_SOURCE_LAYER_SET.has(sourceLayer);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* One queried hit as a GoWay label, or `null` when it is not one.
|
|
135
|
+
*
|
|
136
|
+
* `fallback` is where the user tapped. It is used when the feature has no
|
|
137
|
+
* anchor of its own — a label placed over a POLYGON, such as a park, whose name
|
|
138
|
+
* sits at the pole of inaccessibility that exists only inside MapLibre's
|
|
139
|
+
* placement and not in the tile. The tap is inside the polygon by construction,
|
|
140
|
+
* so it is a truthful point for that thing.
|
|
141
|
+
*
|
|
142
|
+
* Anything derived from a queried geometry goes through `isDrawableCoordinate`
|
|
143
|
+
* for the reason the whole seam exists — a `NaN` reaching MapLibre is a THROW
|
|
144
|
+
* out of the effect that positions things, not a misplaced pin (see
|
|
145
|
+
* `shared.ts`).
|
|
146
|
+
*/
|
|
147
|
+
export function toLabelFeature(candidate, fallback, anchor) {
|
|
148
|
+
const kind = labelKindOf(candidate.sourceLayer);
|
|
149
|
+
if (!kind)
|
|
150
|
+
return null;
|
|
151
|
+
const name = labelNameOf(candidate.properties);
|
|
152
|
+
if (!name)
|
|
153
|
+
return null;
|
|
154
|
+
const own = anchor ?? candidate.coordinate;
|
|
155
|
+
const anchored = isDrawableCoordinate(own);
|
|
156
|
+
const coordinate = anchored ? own : fallback;
|
|
157
|
+
if (!isDrawableCoordinate(coordinate))
|
|
158
|
+
return null;
|
|
159
|
+
const category = stringProp(candidate.properties, 'class');
|
|
160
|
+
const subcategory = stringProp(candidate.properties, 'subclass');
|
|
161
|
+
const rank = numberProp(candidate.properties, 'rank');
|
|
162
|
+
return {
|
|
163
|
+
// The tiles' feature id where there is one, and a coordinate-derived id
|
|
164
|
+
// where there is not, so a label is always keyable. It is NOT a GoWay Place
|
|
165
|
+
// ID and cannot be joined to one — see the note in `types.ts`.
|
|
166
|
+
id: candidate.featureId == null
|
|
167
|
+
? `${candidate.sourceLayer}:${coordinate.longitude.toFixed(6)},${coordinate.latitude.toFixed(6)}`
|
|
168
|
+
: `${candidate.sourceLayer}:${String(candidate.featureId)}`,
|
|
169
|
+
name,
|
|
170
|
+
kind,
|
|
171
|
+
coordinate,
|
|
172
|
+
anchored,
|
|
173
|
+
...(category ? { category } : {}),
|
|
174
|
+
...(subcategory ? { subcategory } : {}),
|
|
175
|
+
...(rank === null ? {} : { rank }),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Every hit in the box, as GoWay labels, each anchored where it really is.
|
|
180
|
+
*
|
|
181
|
+
* Split from the ranking below because the two halves need different things and
|
|
182
|
+
* one platform cannot do them together. Anchoring is PURE GEOMETRY — no
|
|
183
|
+
* projection — which is what lets the native fork do it synchronously for every
|
|
184
|
+
* candidate and then pay for exactly one projection each, instead of one bridge
|
|
185
|
+
* call per vertex of every street in the box.
|
|
186
|
+
*/
|
|
187
|
+
export function labelCandidatesOf(candidates, tapCoordinate) {
|
|
188
|
+
const out = [];
|
|
189
|
+
for (const candidate of candidates) {
|
|
190
|
+
const label = toLabelFeature(candidate, tapCoordinate, anchorFor(candidate, tapCoordinate));
|
|
191
|
+
if (label)
|
|
192
|
+
out.push(label);
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* The label a tap meant, out of everything inside the padded box.
|
|
198
|
+
*
|
|
199
|
+
* ## The rule, written down rather than left to the renderer's return order
|
|
200
|
+
*
|
|
201
|
+
* 1. **Nearest wins.** Distance in SCREEN PIXELS from the tap to the label's
|
|
202
|
+
* own anchor — a point for a POI or a city, and for a street or a river the
|
|
203
|
+
* nearest point on the line, which is a real place on that way rather than a
|
|
204
|
+
* vertex that may be half a tile away. Pixels, not degrees: a degree is a
|
|
205
|
+
* different distance at every latitude, and the user is pointing at pixels.
|
|
206
|
+
* 2. **A label with no anchor of its own is ranked as if it were at the edge of
|
|
207
|
+
* the pad.** A park's name is placed by MapLibre at a pole of inaccessibility
|
|
208
|
+
* that does not exist in the tile, so its only honest position is "somewhere
|
|
209
|
+
* in this box". Scoring it at zero would make it beat everything; scoring it
|
|
210
|
+
* at {@link LABEL_HIT_PAD_PX} makes it lose to anything genuinely nearer and
|
|
211
|
+
* win over nothing.
|
|
212
|
+
* 3. **Then the kind**, most specific first: a POI you can walk into, then the
|
|
213
|
+
* street it is on, then water, then the district or city containing all of
|
|
214
|
+
* them. A city label spans the screen and is never what a tap meant while
|
|
215
|
+
* something smaller is under the finger.
|
|
216
|
+
* 4. **Then the tiles' own `rank`**, OpenMapTiles' importance ordering.
|
|
217
|
+
* 5. **Then the id**, so the answer is deterministic rather than dependent on
|
|
218
|
+
* the order a renderer happened to return its layers in.
|
|
219
|
+
*/
|
|
220
|
+
export function pickLabelFeature(labels, point, project) {
|
|
221
|
+
let best = null;
|
|
222
|
+
let bestScore = null;
|
|
223
|
+
for (const label of labels) {
|
|
224
|
+
const projected = label.anchored ? project(label.coordinate) : null;
|
|
225
|
+
const distance = projected && Number.isFinite(projected.x) && Number.isFinite(projected.y)
|
|
226
|
+
? Math.hypot(projected.x - point.x, projected.y - point.y)
|
|
227
|
+
: // Rule 2: no anchor of its own, so "somewhere in the box".
|
|
228
|
+
LABEL_HIT_PAD_PX;
|
|
229
|
+
const score = [
|
|
230
|
+
distance,
|
|
231
|
+
KIND_ORDER[label.kind],
|
|
232
|
+
label.rank ?? Number.MAX_SAFE_INTEGER,
|
|
233
|
+
label.id,
|
|
234
|
+
];
|
|
235
|
+
if (bestScore === null || compareScores(score, bestScore) < 0) {
|
|
236
|
+
best = label;
|
|
237
|
+
bestScore = score;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return best;
|
|
241
|
+
}
|
|
242
|
+
/** Most specific first. See rule 3. */
|
|
243
|
+
const KIND_ORDER = { poi: 0, road: 1, water: 2, area: 3 };
|
|
244
|
+
function compareScores(a, b) {
|
|
245
|
+
if (a[0] !== b[0])
|
|
246
|
+
return a[0] - b[0];
|
|
247
|
+
if (a[1] !== b[1])
|
|
248
|
+
return a[1] - b[1];
|
|
249
|
+
if (a[2] !== b[2])
|
|
250
|
+
return a[2] - b[2];
|
|
251
|
+
return a[3] < b[3] ? -1 : a[3] > b[3] ? 1 : 0;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Where a line-drawn label should be considered to BE: the point on the way
|
|
255
|
+
* nearest the tap.
|
|
256
|
+
*
|
|
257
|
+
* Not the nearest VERTEX, which on a long straight avenue can be far from a tap
|
|
258
|
+
* that landed squarely on the street. The result is a real point on the way,
|
|
259
|
+
* which is what a pin should sit on and what "directions to this street" needs.
|
|
260
|
+
*
|
|
261
|
+
* ## Why this is flat geometry rather than a projection
|
|
262
|
+
*
|
|
263
|
+
* The search runs in a local planar frame — longitude scaled by `cos(latitude)`
|
|
264
|
+
* — instead of in screen pixels. Over one tile at label zooms the difference is
|
|
265
|
+
* immaterial, and it buys the thing that matters: no projection, so this is
|
|
266
|
+
* pure, testable, and cheap enough for the NATIVE fork, where every projection
|
|
267
|
+
* is a bridge call and projecting every vertex of every street in the box would
|
|
268
|
+
* not be affordable. Both forks therefore anchor identically.
|
|
269
|
+
*
|
|
270
|
+
* Returns `null` for anything that is not a usable line, so the caller falls
|
|
271
|
+
* back to the feature's own point and then to the tap.
|
|
272
|
+
*/
|
|
273
|
+
export function anchorFor(candidate, tap) {
|
|
274
|
+
const path = candidate.path;
|
|
275
|
+
if (!path || path.length === 0)
|
|
276
|
+
return null;
|
|
277
|
+
// One scale for the whole search, taken at the tap: using each vertex's own
|
|
278
|
+
// latitude would make the metric inconsistent between segments.
|
|
279
|
+
const scale = Math.cos((tap.latitude * Math.PI) / 180) || 1;
|
|
280
|
+
const flat = (at) => ({ x: at.longitude * scale, y: at.latitude });
|
|
281
|
+
const here = flat(tap);
|
|
282
|
+
let best = null;
|
|
283
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
284
|
+
let previous = null;
|
|
285
|
+
for (const vertex of path) {
|
|
286
|
+
if (!isDrawableCoordinate(vertex)) {
|
|
287
|
+
previous = null;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (previous) {
|
|
291
|
+
const hit = nearestOnSegment(previous, vertex, here, flat);
|
|
292
|
+
if (hit.distance < bestDistance) {
|
|
293
|
+
bestDistance = hit.distance;
|
|
294
|
+
best = hit.at;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
const at = flat(vertex);
|
|
299
|
+
const distance = Math.hypot(at.x - here.x, at.y - here.y);
|
|
300
|
+
if (distance < bestDistance) {
|
|
301
|
+
bestDistance = distance;
|
|
302
|
+
best = vertex;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
previous = vertex;
|
|
306
|
+
}
|
|
307
|
+
return best;
|
|
308
|
+
}
|
|
309
|
+
/** The point on one segment nearest the tap, in the local planar frame. */
|
|
310
|
+
function nearestOnSegment(a, b, here, flat) {
|
|
311
|
+
const pa = flat(a);
|
|
312
|
+
const pb = flat(b);
|
|
313
|
+
const dx = pb.x - pa.x;
|
|
314
|
+
const dy = pb.y - pa.y;
|
|
315
|
+
const lengthSquared = dx * dx + dy * dy;
|
|
316
|
+
if (lengthSquared === 0) {
|
|
317
|
+
return { at: a, distance: Math.hypot(pa.x - here.x, pa.y - here.y) };
|
|
318
|
+
}
|
|
319
|
+
const raw = ((here.x - pa.x) * dx + (here.y - pa.y) * dy) / lengthSquared;
|
|
320
|
+
const t = raw < 0 ? 0 : raw > 1 ? 1 : raw;
|
|
321
|
+
return {
|
|
322
|
+
at: {
|
|
323
|
+
latitude: a.latitude + t * (b.latitude - a.latitude),
|
|
324
|
+
longitude: a.longitude + t * (b.longitude - a.longitude),
|
|
325
|
+
},
|
|
326
|
+
distance: Math.hypot(pa.x + t * dx - here.x, pa.y + t * dy - here.y),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* The labels currently on screen, deduplicated and capped.
|
|
331
|
+
*
|
|
332
|
+
* Both forks report their viewport's labels so the overlay can avoid stacking
|
|
333
|
+
* its own text on top of one. Two things make
|
|
334
|
+
* that safe to do on every settled frame:
|
|
335
|
+
*
|
|
336
|
+
* - **Dedup by id.** One POI is drawn by a dot layer and a label layer, and a
|
|
337
|
+
* feature that straddles a tile boundary is returned once per tile.
|
|
338
|
+
* - **A cap.** A wide, dense viewport is bounded by the collision system to
|
|
339
|
+
* tens of labels in practice (44 over central Barcelona at z16, measured),
|
|
340
|
+
* but "in practice" is not a bound, and this list crosses into React state.
|
|
341
|
+
*/
|
|
342
|
+
export function collectLabelFeatures(candidates, fallback, limit = 400) {
|
|
343
|
+
const byId = new Map();
|
|
344
|
+
for (const candidate of candidates) {
|
|
345
|
+
if (byId.size >= limit)
|
|
346
|
+
break;
|
|
347
|
+
const label = toLabelFeature(candidate, fallback);
|
|
348
|
+
if (!label || byId.has(label.id))
|
|
349
|
+
continue;
|
|
350
|
+
byId.set(label.id, label);
|
|
351
|
+
}
|
|
352
|
+
return [...byId.values()];
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Whether two reported label sets are the same set.
|
|
356
|
+
*
|
|
357
|
+
* The canvas re-reports on every settled frame, and a fresh array of equal
|
|
358
|
+
* labels would rebuild every marker on the map for no reason. Ids only: a
|
|
359
|
+
* label's name and class come from the same tile feature, so an id that has
|
|
360
|
+
* not changed describes a label that has not changed.
|
|
361
|
+
*/
|
|
362
|
+
export function sameLabelSet(a, b) {
|
|
363
|
+
if (a === b)
|
|
364
|
+
return true;
|
|
365
|
+
if (a.length !== b.length)
|
|
366
|
+
return false;
|
|
367
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
368
|
+
if (a[index].id !== b[index].id)
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
function stringProp(properties, key) {
|
|
374
|
+
const value = properties[key];
|
|
375
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
376
|
+
}
|
|
377
|
+
function numberProp(properties, key) {
|
|
378
|
+
const value = properties[key];
|
|
379
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* The engine's features, with the engine removed. A feature this cannot
|
|
383
|
+
* classify is dropped rather than guessed at: the source-layer decides whether
|
|
384
|
+
* a hit is a POI or a district, and being wrong opens the wrong sheet.
|
|
385
|
+
*/
|
|
386
|
+
export function queriedLabelsOf(features) {
|
|
387
|
+
const out = [];
|
|
388
|
+
for (const feature of features) {
|
|
389
|
+
const properties = feature.properties ?? {};
|
|
390
|
+
const sourceLayer = sourceLayerOf(feature, properties);
|
|
391
|
+
if (!sourceLayer || !isLabelSourceLayer(sourceLayer))
|
|
392
|
+
continue;
|
|
393
|
+
const geometry = feature.geometry;
|
|
394
|
+
// A Point becomes a coordinate; a line becomes a PATH, so a street or a
|
|
395
|
+
// river is anchored at the point on the way the reader pointed at. A
|
|
396
|
+
// polygon (a park) gets neither: its label is placed at a point only the
|
|
397
|
+
// engine knows, and the tap stands in for it — the tap is inside it anyway.
|
|
398
|
+
const coordinate = geometry?.type === 'Point'
|
|
399
|
+
? {
|
|
400
|
+
longitude: geometry.coordinates[0] ?? Number.NaN,
|
|
401
|
+
latitude: geometry.coordinates[1] ?? Number.NaN,
|
|
402
|
+
}
|
|
403
|
+
: null;
|
|
404
|
+
out.push({
|
|
405
|
+
featureId: feature.id ?? null,
|
|
406
|
+
sourceLayer,
|
|
407
|
+
layerId: feature.layer?.id,
|
|
408
|
+
properties,
|
|
409
|
+
coordinate,
|
|
410
|
+
path: pathOf(geometry),
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
function sourceLayerOf(feature, properties) {
|
|
416
|
+
if (typeof feature.sourceLayer === 'string' && feature.sourceLayer)
|
|
417
|
+
return feature.sourceLayer;
|
|
418
|
+
for (const key of ['source-layer', 'sourceLayer', 'layer']) {
|
|
419
|
+
const value = properties[key];
|
|
420
|
+
if (typeof value === 'string' && value)
|
|
421
|
+
return value;
|
|
422
|
+
}
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* A line feature's vertices, capped: a motorway clipped to a tile can carry a
|
|
427
|
+
* great many points and this runs inside a press handler, while the query box
|
|
428
|
+
* is a few dozen pixels across, so the part of the line the reader could have
|
|
429
|
+
* meant is a handful of segments.
|
|
430
|
+
*/
|
|
431
|
+
const MAX_PATH_VERTICES = 512;
|
|
432
|
+
function pathOf(geometry) {
|
|
433
|
+
if (!geometry)
|
|
434
|
+
return null;
|
|
435
|
+
const lines = geometry.type === 'LineString'
|
|
436
|
+
? [geometry.coordinates]
|
|
437
|
+
: geometry.type === 'MultiLineString'
|
|
438
|
+
? geometry.coordinates
|
|
439
|
+
: null;
|
|
440
|
+
if (!lines)
|
|
441
|
+
return null;
|
|
442
|
+
const out = [];
|
|
443
|
+
for (const line of lines) {
|
|
444
|
+
for (const position of line) {
|
|
445
|
+
if (out.length >= MAX_PATH_VERTICES)
|
|
446
|
+
return out;
|
|
447
|
+
out.push({ longitude: position[0] ?? Number.NaN, latitude: position[1] ?? Number.NaN });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return out.length > 0 ? out : null;
|
|
451
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A map that has loaded has a settled camera, and the canvas reports it.
|
|
3
|
+
*
|
|
4
|
+
* Both forks report the camera through `MapViewProps.onCameraChanged` when
|
|
5
|
+
* a move SETTLES: MapLibre's `moveend` on the web, `onRegionDidChange` on
|
|
6
|
+
* native. The camera a map OPENS at is not a move: the web engine sets it with
|
|
7
|
+
* the constructor's own `jumpTo`, which fires `moveend` inside `new Map()`,
|
|
8
|
+
* before any listener exists, and MapLibre Native gives no promise either way.
|
|
9
|
+
* So for a long time nothing reported it, and every caller that needed it got
|
|
10
|
+
* it by accident or wrote a workaround. The explore list got it by accident:
|
|
11
|
+
* its first query waits for the first settled camera (`useExplore`), and that
|
|
12
|
+
* arrived only when something RESIZED the map container (MapLibre's `resize()`
|
|
13
|
+
* fires a `moveend`). A window that never resized (headless Chromium, and any
|
|
14
|
+
* browser whose layout settled before the map was built) showed the list's
|
|
15
|
+
* skeletons until the reader panned.
|
|
16
|
+
*
|
|
17
|
+
* The rule, the same for both forks: ONCE PER ENGINE, when it has loaded, its
|
|
18
|
+
* camera is reported as a settled (`isFinal`) change, `programmatic` because
|
|
19
|
+
* the app opened the map there and the reader did nothing — so it can never
|
|
20
|
+
* arm "Search this area". Unless the camera is not settled:
|
|
21
|
+
*
|
|
22
|
+
* - a move is under way (an animation, a gesture): that move's own settle
|
|
23
|
+
* reports the camera when it gets there;
|
|
24
|
+
* - the camera stands on a frame of a caller-driven animation
|
|
25
|
+
* (`MapCameraOptions.transient`): a flyover frame is not a settled camera,
|
|
26
|
+
* and the animation's closing, ordinary move reports one;
|
|
27
|
+
* - a move started or ended while the camera was being read (native reads it
|
|
28
|
+
* asynchronously): the reading may be stale, and that move reports anyway.
|
|
29
|
+
*
|
|
30
|
+
* "Once per engine" is what makes the forks agree. The web builds a new engine
|
|
31
|
+
* for a new style (at the opening camera), so a new style is a new report;
|
|
32
|
+
* MapLibre Native swaps the style under the same engine and camera, so its
|
|
33
|
+
* `onDidFinishLoadingStyle` for a new style is not. A retry on native remounts
|
|
34
|
+
* the engine and starts a new reporter.
|
|
35
|
+
*/
|
|
36
|
+
import type { GeoBoundingBox, MapCameraChange, ResolvedMapViewport } from './types';
|
|
37
|
+
/** What a fork reads off its engine for a camera report. */
|
|
38
|
+
export interface CameraReading {
|
|
39
|
+
viewport: ResolvedMapViewport;
|
|
40
|
+
bounds: GeoBoundingBox;
|
|
41
|
+
}
|
|
42
|
+
/** Whether the camera is somewhere it will not stay. */
|
|
43
|
+
export interface CameraMotion {
|
|
44
|
+
/** A move (animation or gesture) is under way. */
|
|
45
|
+
moving: boolean;
|
|
46
|
+
/** The last move to end was a frame of a caller-driven animation. */
|
|
47
|
+
transient: boolean;
|
|
48
|
+
}
|
|
49
|
+
/** The report a just-loaded map owes its caller, or `null` while the camera is not settled. */
|
|
50
|
+
export declare function loadedCameraChange(reading: CameraReading, motion: CameraMotion): MapCameraChange | null;
|
|
51
|
+
/** One engine's loaded-camera report: fed its moves, asked once it has loaded. */
|
|
52
|
+
export interface LoadedCameraReporter {
|
|
53
|
+
/** A move began (web `movestart`, native `onRegionWillChange`). */
|
|
54
|
+
moveStarted(): void;
|
|
55
|
+
/** A move ended; `transient` when it was a frame of a caller-driven animation. */
|
|
56
|
+
moveEnded(transient: boolean): void;
|
|
57
|
+
/**
|
|
58
|
+
* The engine has loaded. `read` answers its camera, now or later; the
|
|
59
|
+
* report goes to whatever `listener()` answers then (`null`: no caller).
|
|
60
|
+
* Only the first call per reporter does anything.
|
|
61
|
+
*/
|
|
62
|
+
loaded(read: () => CameraReading | null | Promise<CameraReading | null>, listener: () => ((change: MapCameraChange) => void) | null | undefined): void;
|
|
63
|
+
}
|
|
64
|
+
export declare function createLoadedCameraReporter(): LoadedCameraReporter;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** The report a just-loaded map owes its caller, or `null` while the camera is not settled. */
|
|
2
|
+
export function loadedCameraChange(reading, motion) {
|
|
3
|
+
if (motion.moving || motion.transient)
|
|
4
|
+
return null;
|
|
5
|
+
return {
|
|
6
|
+
viewport: reading.viewport,
|
|
7
|
+
bounds: reading.bounds,
|
|
8
|
+
source: 'programmatic',
|
|
9
|
+
isFinal: true,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function createLoadedCameraReporter() {
|
|
13
|
+
let moving = false;
|
|
14
|
+
let transient = false;
|
|
15
|
+
// Bumped by every move boundary, so an asynchronous read can tell that the
|
|
16
|
+
// camera it is holding may no longer be the one on screen.
|
|
17
|
+
let generation = 0;
|
|
18
|
+
let loaded = false;
|
|
19
|
+
return {
|
|
20
|
+
moveStarted() {
|
|
21
|
+
moving = true;
|
|
22
|
+
generation += 1;
|
|
23
|
+
},
|
|
24
|
+
moveEnded(wasTransient) {
|
|
25
|
+
moving = false;
|
|
26
|
+
transient = wasTransient;
|
|
27
|
+
generation += 1;
|
|
28
|
+
},
|
|
29
|
+
loaded(read, listener) {
|
|
30
|
+
if (loaded)
|
|
31
|
+
return;
|
|
32
|
+
loaded = true;
|
|
33
|
+
if (!listener())
|
|
34
|
+
return;
|
|
35
|
+
const readAt = generation;
|
|
36
|
+
const finish = (reading) => {
|
|
37
|
+
if (!reading || generation !== readAt)
|
|
38
|
+
return;
|
|
39
|
+
const change = loadedCameraChange(reading, { moving, transient });
|
|
40
|
+
const report = listener();
|
|
41
|
+
if (change && report)
|
|
42
|
+
report(change);
|
|
43
|
+
};
|
|
44
|
+
const reading = read();
|
|
45
|
+
if (reading instanceof Promise) {
|
|
46
|
+
reading.then(finish, () => {
|
|
47
|
+
// An engine that cannot answer has no camera to report; the next
|
|
48
|
+
// settled move will.
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
finish(reading);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { GeoCoordinate } from './types';
|
|
2
|
+
export type PermissionState = 'unknown' | 'granted' | 'denied';
|
|
3
|
+
type Listener = () => void;
|
|
4
|
+
/** The one permission store; the platform's permission is one per app. */
|
|
5
|
+
export declare const locationPermission: {
|
|
6
|
+
subscribe(listener: Listener): () => void;
|
|
7
|
+
getSnapshot(): PermissionState;
|
|
8
|
+
/** What `useUserLocation` learned by asking. */
|
|
9
|
+
settle: (next: PermissionState) => void;
|
|
10
|
+
};
|
|
11
|
+
/** The reader's position while a `<UserLocation>` is drawn, in memory only. */
|
|
12
|
+
export declare const userPosition: {
|
|
13
|
+
subscribe(listener: Listener): () => void;
|
|
14
|
+
getSnapshot(): GeoCoordinate | null;
|
|
15
|
+
};
|
|
16
|
+
export {};
|