@theseam/ui-common 2.0.1-beta.109 → 2.0.1-beta.111
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/data-filters/index.d.ts +4 -0
- package/fesm2022/theseam-ui-common-data-filters.mjs +21 -8
- package/fesm2022/theseam-ui-common-data-filters.mjs.map +1 -1
- package/fesm2022/theseam-ui-common-google-maps.mjs +1632 -161
- package/fesm2022/theseam-ui-common-google-maps.mjs.map +1 -1
- package/fesm2022/theseam-ui-common-story-helpers.mjs +55 -2
- package/fesm2022/theseam-ui-common-story-helpers.mjs.map +1 -1
- package/fesm2022/theseam-ui-common-toggle-group.mjs +45 -104
- package/fesm2022/theseam-ui-common-toggle-group.mjs.map +1 -1
- package/google-maps/index.d.ts +426 -13
- package/package.json +1 -1
- package/story-helpers/index.d.ts +4 -2
- package/toggle-group/index.d.ts +24 -18
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { __decorate } from 'tslib';
|
|
2
2
|
import { coerceBooleanProperty, coerceNumberProperty } from '@angular/cdk/coercion';
|
|
3
3
|
import * as i0 from '@angular/core';
|
|
4
|
-
import { InjectionToken, Injectable, Injector, inject, ChangeDetectionStrategy, Component, HostListener, Input, Optional, Inject, ChangeDetectorRef, EventEmitter, forwardRef, ViewChild, Output, HostBinding, Directive, NgModule, LOCALE_ID } from '@angular/core';
|
|
4
|
+
import { InjectionToken, Injectable, Injector, inject, computed, ChangeDetectionStrategy, Component, HostListener, Input, Optional, Inject, ChangeDetectorRef, EventEmitter, forwardRef, ViewChild, Output, HostBinding, Directive, NgModule, LOCALE_ID } from '@angular/core';
|
|
5
5
|
import { NG_VALUE_ACCESSOR } from '@angular/forms';
|
|
6
|
-
import { Observable, Subject, fromEvent, BehaviorSubject, from, of, interval, defer, throwError } from 'rxjs';
|
|
7
|
-
import { takeUntil, switchMap, tap, map, catchError, startWith, filter, take, mapTo } from 'rxjs/operators';
|
|
6
|
+
import { Observable, Subject, fromEvent, BehaviorSubject, from, combineLatest, of, interval, defer, throwError } from 'rxjs';
|
|
7
|
+
import { takeUntil, distinctUntilChanged, switchMap, tap, skip, map, catchError, startWith, filter, take, mapTo } from 'rxjs/operators';
|
|
8
8
|
import { faDrawPolygon, faFileImport, faCrosshairs, faSearchLocation } from '@fortawesome/free-solid-svg-icons';
|
|
9
9
|
import { mixinDisabled, InputBoolean, InputNumber } from '@theseam/ui-common/core';
|
|
10
10
|
import * as i6 from '@theseam/ui-common/menu';
|
|
11
11
|
import { MenuComponent, TheSeamMenuModule } from '@theseam/ui-common/menu';
|
|
12
12
|
import { TerraDraw, TerraDrawPolyLineMode } from 'terra-draw';
|
|
13
13
|
import { TerraDrawGoogleMapsAdapter } from 'terra-draw-google-maps-adapter';
|
|
14
|
-
import { notNullOrUndefined, closePolygons,
|
|
14
|
+
import { notNullOrUndefined, closePolygons, polygonContains, addHoleToPolygon, polygonHasMinDistinctVertices, readGeoFile } from '@theseam/ui-common/utils';
|
|
15
15
|
import { ESCAPE } from '@angular/cdk/keycodes';
|
|
16
16
|
import { toSignal } from '@angular/core/rxjs-interop';
|
|
17
17
|
import * as i2 from '@theseam/ui-common/icon';
|
|
@@ -32,6 +32,7 @@ const MAP_CONTROL_DATA = new InjectionToken('MAP_CONTROL_DATA');
|
|
|
32
32
|
var AppFeaturePropertyName;
|
|
33
33
|
(function (AppFeaturePropertyName) {
|
|
34
34
|
AppFeaturePropertyName["IsSelected"] = "__app__isSelected";
|
|
35
|
+
AppFeaturePropertyName["GroupKey"] = "__app__groupKey";
|
|
35
36
|
})(AppFeaturePropertyName || (AppFeaturePropertyName = {}));
|
|
36
37
|
function isAppFeatureProperty(propertyName) {
|
|
37
38
|
return (Object.values(AppFeaturePropertyName).findIndex((value) => value === propertyName) !== -1);
|
|
@@ -127,6 +128,97 @@ function geoJsonPolygonFromDataFeature(feature) {
|
|
|
127
128
|
closePolygons(polygon);
|
|
128
129
|
return polygon;
|
|
129
130
|
}
|
|
131
|
+
/** Build a google.maps.Data.MultiPolygon from a GeoJSON MultiPolygon. */
|
|
132
|
+
function dataMultiPolygonFromGeoJson(multiPolygon) {
|
|
133
|
+
return new google.maps.Data.MultiPolygon(multiPolygon.coordinates.map((rings) => dataPolygonFromGeoJson({ type: 'Polygon', coordinates: rings })));
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Every Polygon part of a feature, as closed GeoJSON. A Polygon feature yields
|
|
137
|
+
* one; a MultiPolygon yields one per part; anything else yields none.
|
|
138
|
+
*
|
|
139
|
+
* Callers that need to match a drawn shape against existing geometry must use
|
|
140
|
+
* this rather than `geoJsonPolygonFromDataFeature`, which sees only Polygons
|
|
141
|
+
* and silently ignores MultiPolygon features.
|
|
142
|
+
*/
|
|
143
|
+
function polygonsFromDataFeature(feature) {
|
|
144
|
+
const geometry = feature.getGeometry();
|
|
145
|
+
if (geometry === null) {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
if (geometry.getType() === 'Polygon') {
|
|
149
|
+
const polygon = {
|
|
150
|
+
type: 'Polygon',
|
|
151
|
+
coordinates: polygonCoordinates(geometry),
|
|
152
|
+
};
|
|
153
|
+
closePolygons(polygon);
|
|
154
|
+
return [polygon];
|
|
155
|
+
}
|
|
156
|
+
if (geometry.getType() === 'MultiPolygon') {
|
|
157
|
+
return geometry
|
|
158
|
+
.getArray()
|
|
159
|
+
.map((part) => {
|
|
160
|
+
const polygon = {
|
|
161
|
+
type: 'Polygon',
|
|
162
|
+
coordinates: polygonCoordinates(part),
|
|
163
|
+
};
|
|
164
|
+
closePolygons(polygon);
|
|
165
|
+
return polygon;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Cut `hole` into whichever part of `feature`'s geometry fully contains it.
|
|
172
|
+
*
|
|
173
|
+
* Mutates the EXISTING feature (via `setGeometry`) rather than replacing it,
|
|
174
|
+
* so its identity and properties survive. A single-part Polygon feature stays
|
|
175
|
+
* a Polygon; a MultiPolygon feature stays a MultiPolygon with only the
|
|
176
|
+
* containing part modified. Returns whether a containing part was found —
|
|
177
|
+
* when none is, the feature is left untouched.
|
|
178
|
+
*/
|
|
179
|
+
function applyHoleToFeature(feature, hole) {
|
|
180
|
+
const parts = polygonsFromDataFeature(feature);
|
|
181
|
+
const containingIndex = parts.findIndex((part) => polygonContains(part, hole));
|
|
182
|
+
if (containingIndex === -1) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
parts[containingIndex] = addHoleToPolygon(parts[containingIndex], hole);
|
|
186
|
+
feature.setGeometry(parts.length === 1
|
|
187
|
+
? dataPolygonFromGeoJson(parts[0])
|
|
188
|
+
: dataMultiPolygonFromGeoJson({
|
|
189
|
+
type: 'MultiPolygon',
|
|
190
|
+
coordinates: parts.map((p) => p.coordinates),
|
|
191
|
+
}));
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Read a feature as a GeoJSON Feature, geometry and properties together.
|
|
196
|
+
*
|
|
197
|
+
* `excludeProperty` filters properties out by name — used to keep internal
|
|
198
|
+
* `__app__` bookkeeping out of anything handed to a consumer.
|
|
199
|
+
*/
|
|
200
|
+
function geoJsonFeatureFromDataFeature(feature, excludeProperty) {
|
|
201
|
+
const parts = polygonsFromDataFeature(feature);
|
|
202
|
+
if (parts.length === 0) {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
const geometry = feature.getGeometry()?.getType() === 'MultiPolygon'
|
|
206
|
+
? { type: 'MultiPolygon', coordinates: parts.map((p) => p.coordinates) }
|
|
207
|
+
: parts[0];
|
|
208
|
+
const properties = {};
|
|
209
|
+
feature.forEachProperty((value, name) => {
|
|
210
|
+
if (!excludeProperty?.(name)) {
|
|
211
|
+
properties[name] = value;
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
const id = feature.getId();
|
|
215
|
+
return {
|
|
216
|
+
type: 'Feature',
|
|
217
|
+
...(id === undefined ? {} : { id }),
|
|
218
|
+
geometry,
|
|
219
|
+
properties,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
130
222
|
function getBoundsWithAllFeatures(data) {
|
|
131
223
|
const bounds = new google.maps.LatLngBounds();
|
|
132
224
|
data.forEach((feature) => {
|
|
@@ -205,6 +297,301 @@ function createFeatureChangeObservable(data, ngZone) {
|
|
|
205
297
|
});
|
|
206
298
|
}
|
|
207
299
|
|
|
300
|
+
const isDevMode = () => typeof ngDevMode === 'undefined' || !!ngDevMode;
|
|
301
|
+
/**
|
|
302
|
+
* Resolves which group each feature on the data layer belongs to, and assigns
|
|
303
|
+
* keys to features the map itself creates.
|
|
304
|
+
*
|
|
305
|
+
* Keys resolve from `properties[groupProperty]` when present, and otherwise
|
|
306
|
+
* from an internal `__app__groupKey`, which is stripped from serialized output
|
|
307
|
+
* and does not trigger value changes.
|
|
308
|
+
*/
|
|
309
|
+
class FeatureGroupRegistry {
|
|
310
|
+
_data;
|
|
311
|
+
_options;
|
|
312
|
+
_assignedSeq = 0;
|
|
313
|
+
_warnedAboutMissingGroupValues = false;
|
|
314
|
+
_warnedAboutUnsupportedGeometry = false;
|
|
315
|
+
constructor(_data, _options = {}) {
|
|
316
|
+
this._data = _data;
|
|
317
|
+
this._options = _options;
|
|
318
|
+
}
|
|
319
|
+
setOptions(options) {
|
|
320
|
+
this._options = options;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* The group key for a feature, assigning an internal one if it has neither a
|
|
324
|
+
* group property value nor a previously assigned key.
|
|
325
|
+
*
|
|
326
|
+
* Assigning here mutates the feature, but only via an `__app__` property,
|
|
327
|
+
* which neither serializes nor raises a value change.
|
|
328
|
+
*/
|
|
329
|
+
keyOf(feature) {
|
|
330
|
+
const declared = this._declaredKey(feature);
|
|
331
|
+
if (declared !== undefined) {
|
|
332
|
+
return declared;
|
|
333
|
+
}
|
|
334
|
+
const assigned = feature.getProperty(AppFeaturePropertyName.GroupKey);
|
|
335
|
+
if (typeof assigned === 'string' && assigned.length > 0) {
|
|
336
|
+
return assigned;
|
|
337
|
+
}
|
|
338
|
+
this._warnAboutMissingGroupValue();
|
|
339
|
+
const key = `__assigned__${this._assignedSeq++}`;
|
|
340
|
+
feature.setProperty(AppFeaturePropertyName.GroupKey, key);
|
|
341
|
+
return key;
|
|
342
|
+
}
|
|
343
|
+
/** Every feature on the data layer belonging to `key`. */
|
|
344
|
+
featuresIn(key) {
|
|
345
|
+
const features = [];
|
|
346
|
+
this._data.forEach((feature) => {
|
|
347
|
+
if (this.keyOf(feature) === key) {
|
|
348
|
+
features.push(feature);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
return features;
|
|
352
|
+
}
|
|
353
|
+
/** The group a feature belongs to, as emittable GeoJSON. */
|
|
354
|
+
groupOf(feature) {
|
|
355
|
+
return this.group(this.keyOf(feature));
|
|
356
|
+
}
|
|
357
|
+
/** The group for a key, or undefined when no feature carries it. */
|
|
358
|
+
group(key) {
|
|
359
|
+
return this.groupWithSources(key)?.group;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* A group alongside the `Data.Feature` each emitted GeoJSON feature came
|
|
363
|
+
* from, index-aligned.
|
|
364
|
+
*
|
|
365
|
+
* Callers that need to map a `Data.Feature` back to its emitted counterpart
|
|
366
|
+
* must use this rather than indexing `group.features` against
|
|
367
|
+
* `featuresIn(key)` — a feature with unsupported geometry is dropped from
|
|
368
|
+
* the emitted list, which would shift every index after it.
|
|
369
|
+
*/
|
|
370
|
+
groupWithSources(key) {
|
|
371
|
+
const candidates = this.featuresIn(key);
|
|
372
|
+
if (candidates.length === 0) {
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
const sources = [];
|
|
376
|
+
const features = [];
|
|
377
|
+
for (const candidate of candidates) {
|
|
378
|
+
const geoJson = geoJsonFeatureFromDataFeature(candidate, isAppFeatureProperty);
|
|
379
|
+
if (geoJson === undefined) {
|
|
380
|
+
this._warnAboutUnsupportedGeometry();
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
sources.push(candidate);
|
|
384
|
+
features.push(geoJson);
|
|
385
|
+
}
|
|
386
|
+
return { group: { key, features }, sources };
|
|
387
|
+
}
|
|
388
|
+
/** Every group currently on the data layer. */
|
|
389
|
+
groups() {
|
|
390
|
+
const keys = new Set();
|
|
391
|
+
this._data.forEach((feature) => keys.add(this.keyOf(feature)));
|
|
392
|
+
return [...keys]
|
|
393
|
+
.map((key) => this.group(key))
|
|
394
|
+
.filter((g) => g !== undefined);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Start a new group on a feature the map created, writing a real group
|
|
398
|
+
* property when one is configured so the grouping survives serialization.
|
|
399
|
+
*/
|
|
400
|
+
assignNewKey(feature) {
|
|
401
|
+
const key = this._options.newGroupKeyFactory?.() ?? defaultGroupKey();
|
|
402
|
+
if (isDevMode() && this.featuresIn(key).length > 0) {
|
|
403
|
+
console.warn(`[seam-google-maps] newGroupKeyFactory produced the key "${key}", ` +
|
|
404
|
+
`which is already in use. The drawn feature joins that group. ` +
|
|
405
|
+
`The factory is responsible for producing unique keys.`);
|
|
406
|
+
}
|
|
407
|
+
this.assignKey(feature, key);
|
|
408
|
+
return key;
|
|
409
|
+
}
|
|
410
|
+
/** Put a feature into an existing group. */
|
|
411
|
+
assignKey(feature, key) {
|
|
412
|
+
const groupProperty = this._options.groupProperty;
|
|
413
|
+
if (groupProperty) {
|
|
414
|
+
feature.setProperty(groupProperty, key);
|
|
415
|
+
feature.removeProperty(AppFeaturePropertyName.GroupKey);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
feature.setProperty(AppFeaturePropertyName.GroupKey, key);
|
|
419
|
+
}
|
|
420
|
+
_declaredKey(feature) {
|
|
421
|
+
const groupProperty = this._options.groupProperty;
|
|
422
|
+
if (!groupProperty) {
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
const value = feature.getProperty(groupProperty);
|
|
426
|
+
if (value === null || value === undefined || value === '') {
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
return String(value);
|
|
430
|
+
}
|
|
431
|
+
_warnAboutUnsupportedGeometry() {
|
|
432
|
+
if (!isDevMode() || this._warnedAboutUnsupportedGeometry) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
this._warnedAboutUnsupportedGeometry = true;
|
|
436
|
+
console.warn(`[seam-google-maps] a feature's geometry is neither Polygon nor ` +
|
|
437
|
+
`MultiPolygon. It still renders, but is excluded from groups, ` +
|
|
438
|
+
`outputs, and labels.`);
|
|
439
|
+
}
|
|
440
|
+
_warnAboutMissingGroupValue() {
|
|
441
|
+
if (!isDevMode() ||
|
|
442
|
+
!this._options.groupProperty ||
|
|
443
|
+
this._warnedAboutMissingGroupValues) {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
this._warnedAboutMissingGroupValues = true;
|
|
447
|
+
console.warn(`[seam-google-maps] featureGroupProperty is ` +
|
|
448
|
+
`"${this._options.groupProperty}", but at least one feature has no ` +
|
|
449
|
+
`value for it. Each becomes its own group under a session-scoped key ` +
|
|
450
|
+
`that is not serialized. Assign the property during import.`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
function defaultGroupKey() {
|
|
454
|
+
return (globalThis.crypto?.randomUUID?.() ?? `group-${Date.now()}-${Math.random()}`);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const FEATURE_STYLE_OPTIONS_DEFAULT = () => ({
|
|
458
|
+
clickable: true,
|
|
459
|
+
visible: true,
|
|
460
|
+
draggable: false,
|
|
461
|
+
editable: false,
|
|
462
|
+
fillColor: 'teal',
|
|
463
|
+
fillOpacity: 0.3,
|
|
464
|
+
strokeColor: 'blue',
|
|
465
|
+
strokeOpacity: 1,
|
|
466
|
+
strokeWeight: 2,
|
|
467
|
+
});
|
|
468
|
+
const FEATURE_STYLE_OPTIONS_SELECTED = () => ({
|
|
469
|
+
fillColor: 'green',
|
|
470
|
+
fillOpacity: 0.7,
|
|
471
|
+
strokeColor: 'limegreen',
|
|
472
|
+
strokeOpacity: 1,
|
|
473
|
+
strokeWeight: 2,
|
|
474
|
+
});
|
|
475
|
+
const FEATURE_STYLE_OVERRIDE_OPTIONS_HOVERED = () => ({
|
|
476
|
+
strokeColor: 'black',
|
|
477
|
+
strokeOpacity: 1,
|
|
478
|
+
strokeWeight: 4,
|
|
479
|
+
});
|
|
480
|
+
/**
|
|
481
|
+
* Style options a feature may set through its GeoJSON properties.
|
|
482
|
+
*
|
|
483
|
+
* `editable`, `draggable`, and `clickable` are here so a feature can opt OUT —
|
|
484
|
+
* a retired field stays unreshapeable while its neighbours do not. They cannot
|
|
485
|
+
* opt in: the clamp in `computeFeatureStyle` runs last and only ever narrows.
|
|
486
|
+
*/
|
|
487
|
+
const SUPPORTED_PROPERTY_STYLE_OPTIONS = [
|
|
488
|
+
'fillColor',
|
|
489
|
+
'fillOpacity',
|
|
490
|
+
'strokeColor',
|
|
491
|
+
'strokeOpacity',
|
|
492
|
+
'strokeWeight',
|
|
493
|
+
'label',
|
|
494
|
+
'opacity',
|
|
495
|
+
'icon',
|
|
496
|
+
'clickable',
|
|
497
|
+
'visible',
|
|
498
|
+
'editable',
|
|
499
|
+
'draggable',
|
|
500
|
+
];
|
|
501
|
+
/** Merge only the supported subset of one options object into another. */
|
|
502
|
+
function mergeStyleOptions(target, source) {
|
|
503
|
+
if (!source || Object.keys(source).length === 0) {
|
|
504
|
+
return target;
|
|
505
|
+
}
|
|
506
|
+
for (const option of SUPPORTED_PROPERTY_STYLE_OPTIONS) {
|
|
507
|
+
if (Object.prototype.hasOwnProperty.call(source, option)) {
|
|
508
|
+
target[option] = source[option];
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return target;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* The style for a feature, in a fixed precedence order:
|
|
515
|
+
*
|
|
516
|
+
* defaults
|
|
517
|
+
* -> properties.styleOptions
|
|
518
|
+
* -> selected defaults (when selected)
|
|
519
|
+
* -> properties.styleOptionsSelected (when selected)
|
|
520
|
+
* -> interaction clamp (always last)
|
|
521
|
+
*
|
|
522
|
+
* That chain governs the *visual* options (colours, etc.) only. The opt-out
|
|
523
|
+
* flags (`editable`/`draggable`/`clickable`) resolve separately, per key: a
|
|
524
|
+
* selected feature consults `styleOptionsSelected` first and falls back to
|
|
525
|
+
* `styleOptions` only for a key `styleOptionsSelected` doesn't itself
|
|
526
|
+
* mention — never a whole-object fallback. Otherwise a feature that declares
|
|
527
|
+
* `styleOptionsSelected` for an unrelated reason, such as a different
|
|
528
|
+
* selected fill colour, would silently lose an unrelated `editable: false`
|
|
529
|
+
* from `styleOptions` the moment it's selected.
|
|
530
|
+
*
|
|
531
|
+
* The clamp is one-directional. A feature can only narrow what the context
|
|
532
|
+
* permits, never widen it, so no property in an uploaded file can arm editing
|
|
533
|
+
* when the mode says no.
|
|
534
|
+
*
|
|
535
|
+
* A declared `editable: false` also implies `draggable: false`: moving a
|
|
536
|
+
* polygon changes the map's value exactly as much as reshaping it does, so a
|
|
537
|
+
* feature that opts out of one opts out of the other. This implication runs
|
|
538
|
+
* only one way — `draggable: false` alone does not imply `editable: false`, so
|
|
539
|
+
* a feature may still opt out of dragging while remaining reshapeable via its
|
|
540
|
+
* vertex handles. Resolved through the same per-key `wants()` lookup as
|
|
541
|
+
* `editable` itself, so a feature that locks `editable: false` in
|
|
542
|
+
* `styleOptions` cannot be dragged even if `styleOptionsSelected` declares
|
|
543
|
+
* `draggable` for an unrelated reason (or not at all).
|
|
544
|
+
*/
|
|
545
|
+
function computeFeatureStyle(feature, context) {
|
|
546
|
+
const selected = isFeatureSelected(feature);
|
|
547
|
+
const baseDeclared = getStyleOptionsDefinedByFeature(feature);
|
|
548
|
+
const selectedDeclared = selected
|
|
549
|
+
? getSelectedStyleOptionsDefinedByFeature(feature)
|
|
550
|
+
: undefined;
|
|
551
|
+
const options = FEATURE_STYLE_OPTIONS_DEFAULT();
|
|
552
|
+
mergeStyleOptions(options, getStyleOptionsDefinedByFeature(feature));
|
|
553
|
+
if (selected) {
|
|
554
|
+
mergeStyleOptions(options, FEATURE_STYLE_OPTIONS_SELECTED());
|
|
555
|
+
mergeStyleOptions(options, getSelectedStyleOptionsDefinedByFeature(feature));
|
|
556
|
+
}
|
|
557
|
+
// Resolved per key, not per object. A feature that opts out in
|
|
558
|
+
// `styleOptions` must stay opted out even when it also declares a
|
|
559
|
+
// `styleOptionsSelected` for unrelated reasons such as colour — otherwise
|
|
560
|
+
// the clamp stops being one-directional and a retired field becomes
|
|
561
|
+
// editable the moment it is selected.
|
|
562
|
+
const wants = (option) => (selectedDeclared?.[option] ?? baseDeclared?.[option]) !== false;
|
|
563
|
+
const editingArmed = context.editingEnabled && context.geometryEditingArmed && selected;
|
|
564
|
+
const wantsEditable = wants('editable');
|
|
565
|
+
options.editable = wantsEditable && editingArmed;
|
|
566
|
+
// editable: false implies draggable: false (see the doc comment above) —
|
|
567
|
+
// the reverse does not hold, so wants('draggable') is still consulted on
|
|
568
|
+
// its own for a feature that opts out of dragging alone.
|
|
569
|
+
options.draggable = wants('draggable') && wantsEditable && editingArmed;
|
|
570
|
+
options.clickable = wants('clickable') && context.clicksAllowed;
|
|
571
|
+
return options;
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* The hover override, merged with anything the feature declares for hover.
|
|
575
|
+
*
|
|
576
|
+
* `mergeStyleOptions` is shared with `computeFeatureStyle`, and
|
|
577
|
+
* `SUPPORTED_PROPERTY_STYLE_OPTIONS` includes `editable`/`draggable` so a
|
|
578
|
+
* feature can opt OUT of them via `styleOptions`/`styleOptionsSelected`. But
|
|
579
|
+
* this override is applied via `overrideStyle`, with no clamp downstream at
|
|
580
|
+
* all — unlike `computeFeatureStyle`, where the interaction clamp runs last
|
|
581
|
+
* and narrows whatever came before it. So if a consumer's
|
|
582
|
+
* `styleOptionsHovered` declared `editable` or `draggable`, it would apply
|
|
583
|
+
* unclamped: the one path where the "clamp is one-directional and always
|
|
584
|
+
* runs last" guarantee would not hold. A hover override has no business
|
|
585
|
+
* touching either, so both are stripped unconditionally, regardless of what
|
|
586
|
+
* `styleOptionsHovered` declares.
|
|
587
|
+
*/
|
|
588
|
+
function computeFeatureHoverStyle(feature) {
|
|
589
|
+
const options = mergeStyleOptions(FEATURE_STYLE_OVERRIDE_OPTIONS_HOVERED(), getHoveredStyleOptionsDefinedByFeature(feature));
|
|
590
|
+
delete options.editable;
|
|
591
|
+
delete options.draggable;
|
|
592
|
+
return options;
|
|
593
|
+
}
|
|
594
|
+
|
|
208
595
|
// TODO: Close on map losing focus.
|
|
209
596
|
class GoogleMapsContextMenu {
|
|
210
597
|
_map;
|
|
@@ -339,6 +726,284 @@ class GoogleMapsContextMenu {
|
|
|
339
726
|
}
|
|
340
727
|
}
|
|
341
728
|
|
|
729
|
+
/**
|
|
730
|
+
* Multi-field behaviour, where selection and geometry editing are separate
|
|
731
|
+
* concerns.
|
|
732
|
+
*
|
|
733
|
+
* Clicking a polygon selects its whole group and mutates nothing, whether
|
|
734
|
+
* edit mode is on or off — the point being that a stray click can never nudge
|
|
735
|
+
* a vertex on an imported boundary, which is close to unrecoverable without
|
|
736
|
+
* an undo feature. With edit mode on, the selected group's own geometry also
|
|
737
|
+
* goes to Google's vertex/midpoint editor, and a click on open map starts a
|
|
738
|
+
* new drawing.
|
|
739
|
+
*
|
|
740
|
+
* A click on a polygon is suppressed only while a draw is actually in
|
|
741
|
+
* progress — that is the one moment a click is genuinely ambiguous between
|
|
742
|
+
* "select this" and "place a vertex". Between draws there is no ambiguity at
|
|
743
|
+
* all, since Terra Draw is not capturing pointer input, so every polygon
|
|
744
|
+
* stays clickable and selecting a different field never requires leaving
|
|
745
|
+
* edit mode first.
|
|
746
|
+
*/
|
|
747
|
+
class GroupedInteractionModel {
|
|
748
|
+
id = 'grouped';
|
|
749
|
+
onFeatureClick(feature, context) {
|
|
750
|
+
// While a draw is in progress, a click on a polygon is placing a vertex,
|
|
751
|
+
// not a selection. Defensive: the service's data `click` listener already
|
|
752
|
+
// guards on `isDrawing()` before calling in here (see onMapClick's
|
|
753
|
+
// identical defence below), but this model does not rely solely on that
|
|
754
|
+
// guard.
|
|
755
|
+
if (context.isDrawing) {
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
// Selecting mutates nothing, so it is safe on any group regardless of
|
|
759
|
+
// edit mode — including a group other than the one currently selected.
|
|
760
|
+
// Note which polygon was touched, since `Delete` acts on that one alone.
|
|
761
|
+
// Google's vertex editor still receives the click for the selected
|
|
762
|
+
// group's own geometry.
|
|
763
|
+
context.selectGroup(context.groups.keyOf(feature), feature);
|
|
764
|
+
}
|
|
765
|
+
onMapClick(context) {
|
|
766
|
+
// A click while a draw is already in progress is placing a vertex, not a
|
|
767
|
+
// map click. Defensive: the service's map `click` listener already guards
|
|
768
|
+
// on `isDrawing()` before calling in here, but `startDrawing()` calling
|
|
769
|
+
// `setMode('polyline')` again would reset the in-progress path, so this
|
|
770
|
+
// model does not rely solely on the caller's guard.
|
|
771
|
+
if (context.isDrawing) {
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
if (context.editMode) {
|
|
775
|
+
context.startDrawing();
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
context.selectGroup(null, null);
|
|
779
|
+
}
|
|
780
|
+
onDrawFinished(polygon, context) {
|
|
781
|
+
const selectedKey = context.getSelectedKey();
|
|
782
|
+
if (selectedKey === null) {
|
|
783
|
+
// No selection means no field to cut into. Skipping the containment
|
|
784
|
+
// search entirely is what stops a polygon drawn over an unrelated field
|
|
785
|
+
// from punching a hole in it.
|
|
786
|
+
return { kind: 'newFeature', groupKey: null };
|
|
787
|
+
}
|
|
788
|
+
if (context.allowHoles) {
|
|
789
|
+
const target = context.findContainingFeature(polygon, selectedKey);
|
|
790
|
+
if (target) {
|
|
791
|
+
return { kind: 'hole', target };
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return { kind: 'newFeature', groupKey: selectedKey };
|
|
795
|
+
}
|
|
796
|
+
featureFlags(feature, context) {
|
|
797
|
+
if (!context.editMode) {
|
|
798
|
+
return { geometryEditingArmed: false, clicksAllowed: true };
|
|
799
|
+
}
|
|
800
|
+
const selectedKey = context.getSelectedKey();
|
|
801
|
+
const isSelectedGroup = selectedKey !== null && context.groups.keyOf(feature) === selectedKey;
|
|
802
|
+
return {
|
|
803
|
+
// The group stays visibly selected while a draw is in progress (F3) —
|
|
804
|
+
// it is the target the drawn polygon will join — but its vertex/midpoint
|
|
805
|
+
// handles are disarmed for the duration, since Google's edit handles are
|
|
806
|
+
// separate interactive elements that would otherwise compete with Terra
|
|
807
|
+
// Draw for pointer events near the polygon. Only the selected group is
|
|
808
|
+
// ever reshapeable; every group, selected or not, is merely selectable.
|
|
809
|
+
geometryEditingArmed: isSelectedGroup && !context.isDrawing,
|
|
810
|
+
// Suppressed only while Terra Draw is actually capturing pointer input
|
|
811
|
+
// — the one window in which a click is ambiguous between selecting and
|
|
812
|
+
// placing a vertex. Between draws every polygon stays clickable
|
|
813
|
+
// regardless of selection, so a click can switch the selection to a
|
|
814
|
+
// different field without leaving edit mode.
|
|
815
|
+
clicksAllowed: !context.isDrawing,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
allowsContextMenu(feature, context) {
|
|
819
|
+
// Every item this menu offers is a destructive edit (Delete Polygon,
|
|
820
|
+
// Delete Field), so it must not be reachable outside edit mode, and —
|
|
821
|
+
// matching legacy's `isFeatureSelected(feature)` — not for a feature the
|
|
822
|
+
// user has not selected. clicksAllowed above stays gated on drawing alone
|
|
823
|
+
// (a non-selected group must remain clickable so it can BECOME the
|
|
824
|
+
// selection), but offering destructive actions for a field the user has
|
|
825
|
+
// not selected is more surprising than useful, so the menu is narrower
|
|
826
|
+
// than what is merely clickable.
|
|
827
|
+
const selectedKey = context.getSelectedKey();
|
|
828
|
+
return (context.editMode &&
|
|
829
|
+
selectedKey !== null &&
|
|
830
|
+
context.groups.keyOf(feature) === selectedKey);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* The behaviour this module has always had: one boundary, clicking a feature
|
|
836
|
+
* selects it and arms its handles, and the draw button toggles drawing.
|
|
837
|
+
*
|
|
838
|
+
* Two applications depend on this and are not being updated, so it must not
|
|
839
|
+
* drift. Every decision here mirrors the pre-strategy service exactly.
|
|
840
|
+
*/
|
|
841
|
+
class LegacyInteractionModel {
|
|
842
|
+
id = 'legacy';
|
|
843
|
+
onFeatureClick(feature, context) {
|
|
844
|
+
context.selectGroup(context.groups.keyOf(feature), feature);
|
|
845
|
+
}
|
|
846
|
+
onMapClick(context) {
|
|
847
|
+
context.selectGroup(null, null);
|
|
848
|
+
}
|
|
849
|
+
onDrawFinished(polygon, context) {
|
|
850
|
+
// Legacy parity: the pre-strategy service found the exterior feature with
|
|
851
|
+
// geoJsonPolygonFromDataFeature, which returns undefined for MultiPolygon
|
|
852
|
+
// geometry, and that function's caller SKIPPED such a candidate and kept
|
|
853
|
+
// iterating rather than giving up — so a Polygon further along could still
|
|
854
|
+
// match. The shared search is now MultiPolygon-aware for grouped mode;
|
|
855
|
+
// narrow it back here with an accept predicate, filtered DURING the
|
|
856
|
+
// search, so a MultiPolygon candidate is skipped rather than aborting the
|
|
857
|
+
// whole search the way discarding the result afterward would. Two apps
|
|
858
|
+
// depend on this behaviour and are not being updated.
|
|
859
|
+
const target = context.allowHoles
|
|
860
|
+
? context.findContainingFeature(polygon, undefined, (feature) => feature.getGeometry()?.getType() === 'Polygon')
|
|
861
|
+
: undefined;
|
|
862
|
+
return target
|
|
863
|
+
? { kind: 'hole', target }
|
|
864
|
+
: { kind: 'newFeature', groupKey: null };
|
|
865
|
+
}
|
|
866
|
+
featureFlags(feature, context) {
|
|
867
|
+
// Selection alone arms handles here; `editingEnabled` is the only gate,
|
|
868
|
+
// and `computeFeatureStyle` applies it.
|
|
869
|
+
return { geometryEditingArmed: true, clicksAllowed: true };
|
|
870
|
+
}
|
|
871
|
+
allowsContextMenu(feature, context) {
|
|
872
|
+
return isFeatureSelected(feature);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** Below this projected diagonal, a shape is too small to label. */
|
|
877
|
+
const DEFAULT_LABEL_MIN_DIAGONAL_PX = 48;
|
|
878
|
+
/**
|
|
879
|
+
* Whether a group is large enough on screen to carry a label.
|
|
880
|
+
*
|
|
881
|
+
* Uses the diagonal rather than width and height separately, so a long thin
|
|
882
|
+
* field — which has plenty of room for a label — is not hidden for being
|
|
883
|
+
* narrow. This self-tunes as the map zooms out, which is why there is no
|
|
884
|
+
* `minZoom` input.
|
|
885
|
+
*/
|
|
886
|
+
function isLabelVisibleAtSize(widthPx, heightPx, minDiagonalPx = DEFAULT_LABEL_MIN_DIAGONAL_PX) {
|
|
887
|
+
return Math.hypot(widthPx, heightPx) >= minDiagonalPx;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const MAP_FEATURE_LABEL_CLASS = 'seam-map-feature-label';
|
|
891
|
+
/**
|
|
892
|
+
* Renders one label per feature group.
|
|
893
|
+
*
|
|
894
|
+
* The `google.maps.OverlayView` subclass is declared INSIDE the constructor
|
|
895
|
+
* rather than at module scope. This codebase's Google Maps API loader is
|
|
896
|
+
* lazy (see `TheSeamLazyMapsApiLoader`), so `google.maps` is not guaranteed
|
|
897
|
+
* to exist when this module is first imported — a top-level
|
|
898
|
+
* `class X extends google.maps.OverlayView` evaluates that reference at
|
|
899
|
+
* import time and throws `ReferenceError: google is not defined` before the
|
|
900
|
+
* API script has loaded. `GoogleMapsContextMenuOverlayView` in
|
|
901
|
+
* `google-maps-contextmenu.ts` is declared locally for the same reason.
|
|
902
|
+
* `MapFeatureLabelsOverlay` itself is only ever constructed once the map
|
|
903
|
+
* (and therefore the API) is ready, via `GoogleMapsService`, so deferring the
|
|
904
|
+
* subclass to construction time is safe.
|
|
905
|
+
*
|
|
906
|
+
* Internally it is a single OverlayView holding one child div per group,
|
|
907
|
+
* rather than one overlay each: with many fields that is one `draw()` per
|
|
908
|
+
* frame updating N child positions instead of N overlays each doing
|
|
909
|
+
* projection work.
|
|
910
|
+
*
|
|
911
|
+
* Labels live in the `markerLayer` pane, which sits above the `overlayLayer`
|
|
912
|
+
* pane the Data layer renders polygons into (so labels are not decided by DOM
|
|
913
|
+
* insertion order) and below the `floatPane` `GoogleMapsContextMenu` uses (so
|
|
914
|
+
* the context menu still stays on top). `pointer-events: none` means labels
|
|
915
|
+
* never take part in hit testing and cannot interfere with clicks, drawing, or
|
|
916
|
+
* the context-menu overlay.
|
|
917
|
+
*
|
|
918
|
+
* Known limitation: no collision de-confliction. Labels of nearby groups will
|
|
919
|
+
* overlap at low zoom. The size threshold reduces but does not eliminate this.
|
|
920
|
+
*/
|
|
921
|
+
class MapFeatureLabelsOverlay {
|
|
922
|
+
_overlay;
|
|
923
|
+
constructor(getLabels) {
|
|
924
|
+
const container = document.createElement('div');
|
|
925
|
+
container.style.position = 'absolute';
|
|
926
|
+
container.style.left = '0';
|
|
927
|
+
container.style.top = '0';
|
|
928
|
+
container.style.pointerEvents = 'none';
|
|
929
|
+
const elements = new Map();
|
|
930
|
+
let labels = [];
|
|
931
|
+
const elementFor = (label) => {
|
|
932
|
+
let element = elements.get(label.key);
|
|
933
|
+
if (!element) {
|
|
934
|
+
element = document.createElement('div');
|
|
935
|
+
element.className = MAP_FEATURE_LABEL_CLASS;
|
|
936
|
+
element.style.position = 'absolute';
|
|
937
|
+
element.style.transform = 'translate(-50%, -50%)';
|
|
938
|
+
element.style.pointerEvents = 'none';
|
|
939
|
+
element.style.whiteSpace = 'nowrap';
|
|
940
|
+
container.appendChild(element);
|
|
941
|
+
elements.set(label.key, element);
|
|
942
|
+
}
|
|
943
|
+
if (element.textContent !== label.text) {
|
|
944
|
+
element.textContent = label.text;
|
|
945
|
+
}
|
|
946
|
+
return element;
|
|
947
|
+
};
|
|
948
|
+
class Overlay extends google.maps.OverlayView {
|
|
949
|
+
onAdd() {
|
|
950
|
+
this.getPanes()?.markerLayer.appendChild(container);
|
|
951
|
+
}
|
|
952
|
+
onRemove() {
|
|
953
|
+
container.parentElement?.removeChild(container);
|
|
954
|
+
elements.clear();
|
|
955
|
+
}
|
|
956
|
+
/** Re-read the labels and repaint. Call when the value or grouping changes. */
|
|
957
|
+
refresh() {
|
|
958
|
+
labels = getLabels();
|
|
959
|
+
this.draw();
|
|
960
|
+
}
|
|
961
|
+
draw() {
|
|
962
|
+
const projection = this.getProjection();
|
|
963
|
+
if (!projection) {
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
const seen = new Set();
|
|
967
|
+
for (const label of labels) {
|
|
968
|
+
seen.add(label.key);
|
|
969
|
+
const element = elementFor(label);
|
|
970
|
+
const ne = projection.fromLatLngToDivPixel(label.bounds.getNorthEast());
|
|
971
|
+
const sw = projection.fromLatLngToDivPixel(label.bounds.getSouthWest());
|
|
972
|
+
const centre = projection.fromLatLngToDivPixel(label.bounds.getCenter());
|
|
973
|
+
if (!ne || !sw || !centre) {
|
|
974
|
+
element.hidden = true;
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
const visible = isLabelVisibleAtSize(Math.abs(ne.x - sw.x), Math.abs(ne.y - sw.y));
|
|
978
|
+
element.hidden = !visible;
|
|
979
|
+
if (visible) {
|
|
980
|
+
element.style.left = `${centre.x}px`;
|
|
981
|
+
element.style.top = `${centre.y}px`;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
for (const [key, element] of elements) {
|
|
985
|
+
if (!seen.has(key)) {
|
|
986
|
+
element.parentElement?.removeChild(element);
|
|
987
|
+
elements.delete(key);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
this._overlay = new Overlay();
|
|
993
|
+
}
|
|
994
|
+
/** Attach to (or detach from, with `null`) a map. */
|
|
995
|
+
setMap(map) {
|
|
996
|
+
this._overlay.setMap(map);
|
|
997
|
+
}
|
|
998
|
+
/** Re-read the labels and repaint. Call when the value or grouping changes. */
|
|
999
|
+
refresh() {
|
|
1000
|
+
this._overlay.refresh();
|
|
1001
|
+
}
|
|
1002
|
+
destroy() {
|
|
1003
|
+
this._overlay.setMap(null);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
342
1007
|
var MapValueSource;
|
|
343
1008
|
(function (MapValueSource) {
|
|
344
1009
|
MapValueSource["Input"] = "input";
|
|
@@ -386,47 +1051,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImpo
|
|
|
386
1051
|
type: Injectable
|
|
387
1052
|
}], ctorParameters: () => [] });
|
|
388
1053
|
|
|
389
|
-
const FEATURE_STYLE_OPTIONS_DEFAULT = (editingEnabled) => ({
|
|
390
|
-
clickable: true,
|
|
391
|
-
// clickable: editingEnabled,
|
|
392
|
-
visible: true,
|
|
393
|
-
// zIndex?: number;
|
|
394
|
-
// cursor?: string;
|
|
395
|
-
draggable: false,
|
|
396
|
-
editable: false,
|
|
397
|
-
fillColor: 'teal',
|
|
398
|
-
fillOpacity: 0.3,
|
|
399
|
-
strokeColor: 'blue',
|
|
400
|
-
strokeOpacity: 1,
|
|
401
|
-
strokeWeight: 2,
|
|
402
|
-
});
|
|
403
|
-
const FEATURE_STYLE_OPTIONS_SELECTED = (editingEnabled) => ({
|
|
404
|
-
...FEATURE_STYLE_OPTIONS_DEFAULT(editingEnabled),
|
|
405
|
-
draggable: editingEnabled,
|
|
406
|
-
editable: editingEnabled,
|
|
407
|
-
fillColor: 'green',
|
|
408
|
-
fillOpacity: 0.7,
|
|
409
|
-
strokeColor: 'limegreen',
|
|
410
|
-
strokeOpacity: 1,
|
|
411
|
-
strokeWeight: 2,
|
|
412
|
-
});
|
|
413
|
-
const FEATURE_STYLE_OVERRIDE_OPTIONS_HOVERED = (editingEnabled) => ({
|
|
414
|
-
strokeColor: 'black',
|
|
415
|
-
strokeOpacity: 1,
|
|
416
|
-
strokeWeight: 4,
|
|
417
|
-
});
|
|
418
|
-
const SUPPORTED_PROPERTY_STYLE_OPTIONS = [
|
|
419
|
-
'fillColor',
|
|
420
|
-
'fillOpacity',
|
|
421
|
-
'strokeColor',
|
|
422
|
-
'strokeOpacity',
|
|
423
|
-
'strokeWeight',
|
|
424
|
-
'label',
|
|
425
|
-
'opacity',
|
|
426
|
-
'icon',
|
|
427
|
-
'clickable',
|
|
428
|
-
'visible',
|
|
429
|
-
];
|
|
430
1054
|
class GoogleMapsService {
|
|
431
1055
|
_mapValueManager;
|
|
432
1056
|
_ngZone;
|
|
@@ -436,6 +1060,46 @@ class GoogleMapsService {
|
|
|
436
1060
|
_editingEnabledSubject = new BehaviorSubject(true);
|
|
437
1061
|
_terraDraw;
|
|
438
1062
|
_terraDrawReady = false;
|
|
1063
|
+
// Terra Draw's Google Maps adapter can lose pointer capture at the adapter
|
|
1064
|
+
// level after a completed draw session (terra-draw#710): every click after
|
|
1065
|
+
// that lands on nothing, and a drag pans the map instead of editing.
|
|
1066
|
+
// Recreating the whole `TerraDraw` instance (and its adapter) is the only
|
|
1067
|
+
// thing that restores it. `stopDrawing()` triggers this — EAGERLY, right
|
|
1068
|
+
// when the session ends, not lazily on the next `startDrawing()` — but
|
|
1069
|
+
// only for a session that actually placed a coordinate (see
|
|
1070
|
+
// `_hasPlacedVertex()`): that is the only kind that ever touched the
|
|
1071
|
+
// adapter's pointer capture, so an armed-but-empty session (armed, then
|
|
1072
|
+
// cancelled with zero vertices placed — e.g. by `Escape`, or by
|
|
1073
|
+
// `setEditMode(true)`'s auto-arm getting turned straight back off) does
|
|
1074
|
+
// not need it and does not pay for it.
|
|
1075
|
+
//
|
|
1076
|
+
// Recreating eagerly (rather than deferring to the next `startDrawing()`)
|
|
1077
|
+
// means the new instance's `ready` has every chance to have already fired
|
|
1078
|
+
// by the time the user's NEXT click asks to start a second draw, so that
|
|
1079
|
+
// click is not the one racing the async gap. A `startDrawing()` call that
|
|
1080
|
+
// still arrives before `ready` fires — instance rebuild is fast but not
|
|
1081
|
+
// free — queues itself via `_pendingStartDrawing` below rather than being
|
|
1082
|
+
// dropped, so nothing is lost even in that case.
|
|
1083
|
+
_pendingStartDrawing = false;
|
|
1084
|
+
/**
|
|
1085
|
+
* One-shot suppression for the map `click` listener, armed the instant a
|
|
1086
|
+
* draw finishes (see `_armMapClickSuppression()` for why, and its call
|
|
1087
|
+
* site in `_onDrawFinished()`).
|
|
1088
|
+
*
|
|
1089
|
+
* `domEvent.timeStamp` was tried first, since it is a `DOMHighResTimeStamp`
|
|
1090
|
+
* on the same origin as `performance.now()` and looks like a clean,
|
|
1091
|
+
* delay-free discriminator: a stale click's timestamp should predate the
|
|
1092
|
+
* moment the draw finished. It does not hold up empirically. Driving real
|
|
1093
|
+
* mouse draws against the live Storybook and logging both the map `click`
|
|
1094
|
+
* listener and `stopDrawing()` (see .superpowers/closing-click-report.md)
|
|
1095
|
+
* caught the echo repeatedly, and every single time its `domEvent.timeStamp`
|
|
1096
|
+
* was a few milliseconds AFTER `stopDrawing()`'s own timestamp, not before
|
|
1097
|
+
* — indistinguishable from a genuinely fresh click by timestamp alone.
|
|
1098
|
+
* Google appears to stamp its own synthetic `click` at the moment IT
|
|
1099
|
+
* dispatches, not at the original pointer event's time, so there is no
|
|
1100
|
+
* "stale-looking" timestamp to compare against.
|
|
1101
|
+
*/
|
|
1102
|
+
_suppressNextMapClick = false;
|
|
439
1103
|
_drawingSubject = new BehaviorSubject(false);
|
|
440
1104
|
drawing$ = this._drawingSubject.asObservable();
|
|
441
1105
|
_featureContextMenu = null;
|
|
@@ -443,6 +1107,47 @@ class GoogleMapsService {
|
|
|
443
1107
|
_baseLatLng;
|
|
444
1108
|
_padding;
|
|
445
1109
|
_allowDrawingHoleInPolygon = false;
|
|
1110
|
+
_model = new LegacyInteractionModel();
|
|
1111
|
+
_interactionModeSubject = new BehaviorSubject('legacy');
|
|
1112
|
+
interactionMode$ = this._interactionModeSubject.asObservable();
|
|
1113
|
+
_groups;
|
|
1114
|
+
_groupOptions = {};
|
|
1115
|
+
_focusedFeature = null;
|
|
1116
|
+
_styleFn;
|
|
1117
|
+
_labelProperty;
|
|
1118
|
+
_labelsOverlay;
|
|
1119
|
+
_warnedAboutLabelDisagreement = false;
|
|
1120
|
+
_selectionSubject = new BehaviorSubject(null);
|
|
1121
|
+
/**
|
|
1122
|
+
* `distinctUntilChanged` collapses repeated `null`s. Several paths clear the
|
|
1123
|
+
* selection defensively — `setData`, `clearSelection`, the declarative
|
|
1124
|
+
* `selectedGroupKey` application on map-ready — and each would otherwise
|
|
1125
|
+
* emit its own `null` to consumers before the user has touched anything.
|
|
1126
|
+
*
|
|
1127
|
+
* Non-null targets are rebuilt on every change, so reference equality never
|
|
1128
|
+
* suppresses a real one, including re-selecting the same group with a
|
|
1129
|
+
* different focused polygon.
|
|
1130
|
+
*/
|
|
1131
|
+
selection$ = this._selectionSubject.pipe(distinctUntilChanged());
|
|
1132
|
+
_hoverSubject = new BehaviorSubject(null);
|
|
1133
|
+
/** Same reasoning as `selection$`; `mouseout` repeats `null` freely. */
|
|
1134
|
+
hover$ = this._hoverSubject.pipe(distinctUntilChanged());
|
|
1135
|
+
/**
|
|
1136
|
+
* The group of the feature a `contextmenu` event last landed on. Kept
|
|
1137
|
+
* distinct from `selection$` even though `allowsContextMenu()` now requires
|
|
1138
|
+
* the right-clicked feature's group to already be the selection — so, for
|
|
1139
|
+
* as long as the menu stays open, the two are provably the same group.
|
|
1140
|
+
* Collapsing onto `selection$` would re-couple "what the menu acts on" to
|
|
1141
|
+
* "whatever is currently selected", which is exactly the coupling this
|
|
1142
|
+
* subject was introduced to break when the menu could still open for a
|
|
1143
|
+
* non-selected group. That rule has already moved twice; keeping this
|
|
1144
|
+
* separate means a future loosening of `allowsContextMenu()` does not have
|
|
1145
|
+
* to re-invent it.
|
|
1146
|
+
*/
|
|
1147
|
+
_contextMenuTargetSubject = new BehaviorSubject(null);
|
|
1148
|
+
contextMenuTarget$ = this._contextMenuTargetSubject.asObservable();
|
|
1149
|
+
_editModeSubject = new BehaviorSubject(false);
|
|
1150
|
+
editMode$ = this._editModeSubject.asObservable();
|
|
446
1151
|
// TODO: Move to a better place than the map wrapper service.
|
|
447
1152
|
_fileInputHandler;
|
|
448
1153
|
googleMap;
|
|
@@ -459,11 +1164,16 @@ class GoogleMapsService {
|
|
|
459
1164
|
this.mapReady$ = this._mapReadySubject.asObservable();
|
|
460
1165
|
}
|
|
461
1166
|
ngOnDestroy() {
|
|
462
|
-
|
|
463
|
-
this._terraDraw.stop();
|
|
464
|
-
}
|
|
465
|
-
this._terraDraw = undefined;
|
|
1167
|
+
this._disposeTerraDraw();
|
|
466
1168
|
this._drawingSubject.complete();
|
|
1169
|
+
this._selectionSubject.complete();
|
|
1170
|
+
this._hoverSubject.complete();
|
|
1171
|
+
this._contextMenuTargetSubject.complete();
|
|
1172
|
+
this._editModeSubject.complete();
|
|
1173
|
+
this._interactionModeSubject.complete();
|
|
1174
|
+
this._groups = undefined;
|
|
1175
|
+
this._labelsOverlay?.destroy();
|
|
1176
|
+
this._labelsOverlay = undefined;
|
|
467
1177
|
this._ngUnsubscribe.next();
|
|
468
1178
|
this._ngUnsubscribe.complete();
|
|
469
1179
|
}
|
|
@@ -497,11 +1207,8 @@ class GoogleMapsService {
|
|
|
497
1207
|
this.googleMap.data.revertStyle();
|
|
498
1208
|
if (!enabled) {
|
|
499
1209
|
this.stopDrawing();
|
|
500
|
-
this.
|
|
501
|
-
|
|
502
|
-
setFeatureSelected(f, false);
|
|
503
|
-
}
|
|
504
|
-
});
|
|
1210
|
+
this.setEditMode(false);
|
|
1211
|
+
this.clearSelection();
|
|
505
1212
|
}
|
|
506
1213
|
}
|
|
507
1214
|
}
|
|
@@ -527,27 +1234,120 @@ class GoogleMapsService {
|
|
|
527
1234
|
mapData.remove(f);
|
|
528
1235
|
}
|
|
529
1236
|
});
|
|
1237
|
+
// Every deleted feature was selected, so nothing should still read as
|
|
1238
|
+
// selected afterward. Re-sync `selection$` and `_focusedFeature` the same
|
|
1239
|
+
// way `deleteFocusedFeature()` and `setData()` already do, rather than
|
|
1240
|
+
// leaving them pointing at a group that no longer exists. In 'legacy'
|
|
1241
|
+
// mode nothing consumes selection$ today, and no remaining feature's raw
|
|
1242
|
+
// selected flag changes here (they were already false), so this is inert
|
|
1243
|
+
// there.
|
|
1244
|
+
this.clearSelection();
|
|
530
1245
|
}
|
|
531
|
-
/**
|
|
1246
|
+
/**
|
|
1247
|
+
* Delete every feature in `key`'s group. Backs the grouped "Delete Field"
|
|
1248
|
+
* context-menu item.
|
|
1249
|
+
*
|
|
1250
|
+
* `allowsContextMenu()` now requires the right-clicked feature's group to
|
|
1251
|
+
* already be the selection, so `key` here is always the selected group's
|
|
1252
|
+
* key by the time this runs — making this call equivalent in practice to
|
|
1253
|
+
* `deleteSelection()`. Kept as its own method anyway: it names the thing
|
|
1254
|
+
* the menu item actually acts on (the group the menu opened for) rather
|
|
1255
|
+
* than relying on the coincidence that it currently matches whatever is
|
|
1256
|
+
* selected, a coincidence this design has already stopped being true once
|
|
1257
|
+
* before and could again.
|
|
1258
|
+
*/
|
|
1259
|
+
deleteGroup(key) {
|
|
1260
|
+
this._assertInitialized();
|
|
1261
|
+
const mapData = this.googleMap.data;
|
|
1262
|
+
const wasSelected = this._selectionSubject.value?.group.key === key;
|
|
1263
|
+
const focusedInGroup = this._focusedFeature !== null &&
|
|
1264
|
+
this._registry.keyOf(this._focusedFeature) === key;
|
|
1265
|
+
const contextMenuTargetInGroup = this._contextMenuTargetSubject.value?.group.key === key;
|
|
1266
|
+
this._registry.featuresIn(key).forEach((f) => mapData.remove(f));
|
|
1267
|
+
if (wasSelected) {
|
|
1268
|
+
this._applySelection(null, null);
|
|
1269
|
+
}
|
|
1270
|
+
else if (focusedInGroup) {
|
|
1271
|
+
this._focusedFeature = null;
|
|
1272
|
+
}
|
|
1273
|
+
// The whole group is gone, so a context-menu target pointing at it — set
|
|
1274
|
+
// by whichever interaction opened the menu, not necessarily this one — is
|
|
1275
|
+
// now a dangling reference. Only clear it when it actually named this
|
|
1276
|
+
// group; an unrelated still-open menu's target must survive.
|
|
1277
|
+
if (contextMenuTargetInGroup) {
|
|
1278
|
+
this._contextMenuTargetSubject.next(null);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Whether polygon drawing mode is currently active.
|
|
1283
|
+
*
|
|
1284
|
+
* Reads `_drawingSubject` — set by `startDrawing()`/`stopDrawing()`, which
|
|
1285
|
+
* this service fully controls — rather than asking Terra Draw's own
|
|
1286
|
+
* `getMode()`. The two are kept in lockstep by every call to those two
|
|
1287
|
+
* methods, so this is not a behaviour change; it exists so a stuck Terra
|
|
1288
|
+
* Draw mode can never silently disagree with, and disarm, the rest of this
|
|
1289
|
+
* service's drawing-state bookkeeping (F3's `geometryEditingArmed`
|
|
1290
|
+
* included). `stopDrawing()` still always calls `_terraDraw.setMode('static')`
|
|
1291
|
+
* to make Terra Draw itself release its cursor override and any
|
|
1292
|
+
* in-progress geometry, regardless of which source `isDrawing()` reads.
|
|
1293
|
+
*/
|
|
532
1294
|
isDrawing() {
|
|
533
|
-
return this.
|
|
1295
|
+
return this._drawingSubject.value;
|
|
534
1296
|
}
|
|
535
1297
|
/** Enter polygon drawing mode. */
|
|
536
1298
|
startDrawing() {
|
|
537
|
-
if (!this._terraDraw ||
|
|
1299
|
+
if (!this._terraDraw ||
|
|
1300
|
+
!this.isEditingEnabled() ||
|
|
1301
|
+
// Already drawing: `setMode('polyline')` resets the in-progress path,
|
|
1302
|
+
// so a click that lands here mid-draw (F4) must not re-enter drawing
|
|
1303
|
+
// mode. `onMapClick` already guards on `context.isDrawing` before
|
|
1304
|
+
// calling in, and the map `click` listener guards on `isDrawing()`
|
|
1305
|
+
// before calling the model at all — this is the last line of defence.
|
|
1306
|
+
this.isDrawing()) {
|
|
538
1307
|
return;
|
|
539
1308
|
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
1309
|
+
if (!this._terraDrawReady) {
|
|
1310
|
+
// A `TerraDraw` recreate is in flight — either the eager rebuild
|
|
1311
|
+
// `stopDrawing()` kicks off after a real draw session ends (see
|
|
1312
|
+
// `_hasPlacedVertex()`), or, rarely, first-ever init hasn't reported
|
|
1313
|
+
// `ready` yet. Recreation itself is synchronous, but the new
|
|
1314
|
+
// instance's `ready` event is not (see `_createTerraDraw()`), so queue
|
|
1315
|
+
// this call rather than silently dropping the click that made it —
|
|
1316
|
+
// the `ready` handler below finishes entering drawing mode once the
|
|
1317
|
+
// new instance is actually usable.
|
|
1318
|
+
this._pendingStartDrawing = true;
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
this._enterDrawingMode();
|
|
1322
|
+
}
|
|
1323
|
+
/**
|
|
1324
|
+
* Clear any selection when entering drawing mode, but only in 'legacy'
|
|
1325
|
+
* mode. There, a selected feature and the shape being drawn are unrelated,
|
|
1326
|
+
* so leaving the old one visibly selected would read as one shape. In
|
|
1327
|
+
* 'grouped' mode they are related: the selected group is exactly the
|
|
1328
|
+
* target the drawn polygon will join, so it keeps its selected styling —
|
|
1329
|
+
* `GroupedInteractionModel.featureFlags()` disarms its edit handles for
|
|
1330
|
+
* the duration instead (F3), so they don't compete with Terra Draw for
|
|
1331
|
+
* pointer events.
|
|
1332
|
+
*
|
|
1333
|
+
* Split out of `startDrawing()` so a recreate-in-flight (see
|
|
1334
|
+
* `_pendingStartDrawing`) can defer this until the new instance's `ready`
|
|
1335
|
+
* event actually fires, instead of entering drawing mode against an
|
|
1336
|
+
* instance that isn't ready yet.
|
|
1337
|
+
*/
|
|
1338
|
+
_enterDrawingMode() {
|
|
543
1339
|
this._assertInitialized();
|
|
544
|
-
this.
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
1340
|
+
if (this._model.id === 'legacy') {
|
|
1341
|
+
this.googleMap.data.forEach((f) => {
|
|
1342
|
+
if (isFeatureSelected(f)) {
|
|
1343
|
+
setFeatureSelected(f, false);
|
|
1344
|
+
}
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
this._terraDraw?.setMode('polyline');
|
|
550
1348
|
this._drawingSubject.next(true);
|
|
1349
|
+
this._refreshStyles();
|
|
1350
|
+
this._labelsOverlay?.refresh();
|
|
551
1351
|
}
|
|
552
1352
|
/**
|
|
553
1353
|
* Cancel any in-progress drawing and leave drawing mode. Switching to the
|
|
@@ -557,13 +1357,93 @@ class GoogleMapsService {
|
|
|
557
1357
|
if (!this._terraDraw || !this._terraDrawReady) {
|
|
558
1358
|
return;
|
|
559
1359
|
}
|
|
1360
|
+
const wasDrawing = this.isDrawing();
|
|
1361
|
+
// Read BEFORE setMode('static') below — switching the active mode away
|
|
1362
|
+
// stops it, which resets its own state, so this only means anything
|
|
1363
|
+
// while the polyline mode is still the one active.
|
|
1364
|
+
const placedVertex = wasDrawing && this._hasPlacedVertex();
|
|
560
1365
|
this._terraDraw.setMode('static');
|
|
561
1366
|
this._drawingSubject.next(false);
|
|
1367
|
+
if (placedVertex) {
|
|
1368
|
+
// A real draw session — one that actually placed a coordinate, and so
|
|
1369
|
+
// actually engaged the adapter's pointer capture — just ended
|
|
1370
|
+
// (finished OR cancelled). That capture is what terra-draw#710 can
|
|
1371
|
+
// leave broken. An armed-but-empty session (armed via a click, or via
|
|
1372
|
+
// setEditMode(true)'s auto-arm, then cancelled — e.g. by Escape —
|
|
1373
|
+
// with zero vertices placed) never touched it and does not need this.
|
|
1374
|
+
//
|
|
1375
|
+
// Recreate EAGERLY, right now, rather than deferring to the next
|
|
1376
|
+
// startDrawing() call: the new instance then has the rest of this
|
|
1377
|
+
// moment, plus however long the user takes to look at what they just
|
|
1378
|
+
// did, to finish coming up — so the click that starts the NEXT draw
|
|
1379
|
+
// does not itself race the async `ready` gap. `startDrawing()`'s own
|
|
1380
|
+
// `!_terraDrawReady` branch (queuing via `_pendingStartDrawing`) is
|
|
1381
|
+
// the safety net for a start that still arrives before `ready` fires.
|
|
1382
|
+
this._recreateTerraDraw();
|
|
1383
|
+
}
|
|
1384
|
+
// Re-arms the selected group's edit handles in 'grouped' mode: nothing
|
|
1385
|
+
// else changes a feature's own properties here, so nothing else would
|
|
1386
|
+
// make Data re-evaluate the style function and pick up
|
|
1387
|
+
// `featureFlags().geometryEditingArmed` no longer being disarmed by
|
|
1388
|
+
// `isDrawing` (F3).
|
|
1389
|
+
this._refreshStyles();
|
|
1390
|
+
this._labelsOverlay?.refresh();
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* Whether the active Terra Draw mode has actually placed a coordinate.
|
|
1394
|
+
* Terra Draw's own mode state machine transitions from `'started'`
|
|
1395
|
+
* (armed, no coordinate committed yet) to `'drawing'` the instant the
|
|
1396
|
+
* first vertex lands (`TerraDrawPolyLineMode`'s internal `setDrawing()`,
|
|
1397
|
+
* confirmed against `terra-draw`'s own source). Read directly from Terra
|
|
1398
|
+
* Draw's state rather than inferred from this service's own flags, so it
|
|
1399
|
+
* reflects whether the adapter's pointer capture was actually
|
|
1400
|
+
* engaged — the thing terra-draw#710 is actually about — rather than
|
|
1401
|
+
* merely whether this service thought a draw was armed.
|
|
1402
|
+
*/
|
|
1403
|
+
_hasPlacedVertex() {
|
|
1404
|
+
return this._terraDraw?.getModeState() === 'drawing';
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Stop (if running) and drop the current `TerraDraw` instance. `TerraDraw#
|
|
1408
|
+
* stop()` deregisters its adapter, which for the Google Maps adapter's
|
|
1409
|
+
* `isolatedData: true` mode calls `this._data.setMap(null)` and drops the
|
|
1410
|
+
* reference — so this never leaves an orphaned or duplicate Data layer
|
|
1411
|
+
* behind, across any number of calls.
|
|
1412
|
+
*/
|
|
1413
|
+
_disposeTerraDraw() {
|
|
1414
|
+
if (this._terraDraw?.enabled) {
|
|
1415
|
+
this._terraDraw.stop();
|
|
1416
|
+
}
|
|
1417
|
+
this._terraDraw = undefined;
|
|
1418
|
+
this._terraDrawReady = false;
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Dispose the current `TerraDraw` instance (and its adapter) and build a
|
|
1422
|
+
* fresh one in its place. This is the terra-draw#710 recovery: the old
|
|
1423
|
+
* instance's listeners die with it (its adapter is deregistered by
|
|
1424
|
+
* `stop()`, so nothing further reaches it), and `_createTerraDraw()` wires
|
|
1425
|
+
* up brand new `ready`/`finish` listeners on the new instance — never the
|
|
1426
|
+
* stale closures from a previous instance.
|
|
1427
|
+
*/
|
|
1428
|
+
_recreateTerraDraw() {
|
|
1429
|
+
this._disposeTerraDraw();
|
|
1430
|
+
this._terraDraw = this._createTerraDraw();
|
|
562
1431
|
}
|
|
563
1432
|
_initTerraDraw() {
|
|
564
1433
|
if (notNullOrUndefined(this._terraDraw)) {
|
|
565
1434
|
throw Error(`Terra Draw is already initialized.`);
|
|
566
1435
|
}
|
|
1436
|
+
this._terraDraw = this._createTerraDraw();
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Build and start a new `TerraDraw` instance with its Google Maps adapter,
|
|
1440
|
+
* wiring up this instance's own `ready`/`finish` listeners. Called both by
|
|
1441
|
+
* `_initTerraDraw()` (first-ever init, guarded against running twice) and
|
|
1442
|
+
* by `_recreateTerraDraw()` (every terra-draw#710 recovery afterward) —
|
|
1443
|
+
* the guard against accidental double-init only lives in `_initTerraDraw()`
|
|
1444
|
+
* itself, so recreation is free to call this as many times as needed.
|
|
1445
|
+
*/
|
|
1446
|
+
_createTerraDraw() {
|
|
567
1447
|
this._assertInitialized();
|
|
568
1448
|
// The Google Maps adapter creates an OverlayView on the map; ensure the map
|
|
569
1449
|
// element has an id (harmless if one is already present).
|
|
@@ -651,6 +1531,25 @@ class GoogleMapsService {
|
|
|
651
1531
|
this._terraDrawReady = true;
|
|
652
1532
|
// Start in the resting (non-drawing) mode.
|
|
653
1533
|
draw.setMode('static');
|
|
1534
|
+
// A `startDrawing()` call arrived while THIS instance was still
|
|
1535
|
+
// starting up (terra-draw#710 recovery, or plain first-time init) and
|
|
1536
|
+
// queued itself rather than being dropped — finish the job now, but
|
|
1537
|
+
// only if nothing since then made drawing invalid: editing disabled,
|
|
1538
|
+
// somehow already drawing, or — grouped mode only, where edit mode is
|
|
1539
|
+
// its own on/off concept `startDrawing()` itself never re-checks —
|
|
1540
|
+
// edit mode having been turned off while this was in flight. Mirrors
|
|
1541
|
+
// exactly the check `GroupedInteractionModel.onMapClick()` made before
|
|
1542
|
+
// calling in, just re-run after the async gap instead of trusting it
|
|
1543
|
+
// still holds.
|
|
1544
|
+
if (this._pendingStartDrawing) {
|
|
1545
|
+
this._pendingStartDrawing = false;
|
|
1546
|
+
const stillValid = this.isEditingEnabled() &&
|
|
1547
|
+
!this.isDrawing() &&
|
|
1548
|
+
(this._model.id !== 'grouped' || this.isEditMode());
|
|
1549
|
+
if (stillValid) {
|
|
1550
|
+
this._enterDrawingMode();
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
654
1553
|
});
|
|
655
1554
|
draw.on('finish', (id, context) => {
|
|
656
1555
|
if (context.action !== 'draw') {
|
|
@@ -659,7 +1558,7 @@ class GoogleMapsService {
|
|
|
659
1558
|
this._ngZone.run(() => this._onDrawFinished(id));
|
|
660
1559
|
});
|
|
661
1560
|
draw.start();
|
|
662
|
-
|
|
1561
|
+
return draw;
|
|
663
1562
|
}
|
|
664
1563
|
addControl(element, position) {
|
|
665
1564
|
this._assertInitialized();
|
|
@@ -668,8 +1567,15 @@ class GoogleMapsService {
|
|
|
668
1567
|
async setData(data) {
|
|
669
1568
|
this._assertInitialized();
|
|
670
1569
|
removeAllFeatures(this.googleMap.data);
|
|
1570
|
+
// Every prior feature is gone, so nothing selected or hovered survives it —
|
|
1571
|
+
// clear both rather than leaving selection$/hover$ referencing removed
|
|
1572
|
+
// features.
|
|
1573
|
+
this.clearSelection();
|
|
1574
|
+
this._hoverSubject.next(null);
|
|
1575
|
+
this._contextMenuTargetSubject.next(null);
|
|
671
1576
|
this.googleMap.data.addGeoJson(data);
|
|
672
1577
|
this.googleMap.fitBounds(getBoundsWithAllFeatures(this.googleMap.data), this._padding);
|
|
1578
|
+
this._labelsOverlay?.refresh();
|
|
673
1579
|
}
|
|
674
1580
|
// TODO: Refactor out of the service meant to just wrap the google maps api.
|
|
675
1581
|
reCenterOnFeatures() {
|
|
@@ -695,88 +1601,442 @@ class GoogleMapsService {
|
|
|
695
1601
|
getFileInputHandler() {
|
|
696
1602
|
return this._fileInputHandler;
|
|
697
1603
|
}
|
|
698
|
-
|
|
1604
|
+
setInteractionMode(mode) {
|
|
1605
|
+
this._model =
|
|
1606
|
+
mode === 'grouped'
|
|
1607
|
+
? new GroupedInteractionModel()
|
|
1608
|
+
: new LegacyInteractionModel();
|
|
1609
|
+
if (mode !== 'grouped') {
|
|
1610
|
+
this._editModeSubject.next(false);
|
|
1611
|
+
}
|
|
1612
|
+
this._interactionModeSubject.next(mode);
|
|
1613
|
+
this._refreshStyles();
|
|
1614
|
+
}
|
|
1615
|
+
setGroupOptions(options) {
|
|
1616
|
+
this._groupOptions = options;
|
|
1617
|
+
this._groups?.setOptions(options);
|
|
1618
|
+
this._refreshStyles();
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* Name of the GeoJSON property to render as a per-group label. `undefined`
|
|
1622
|
+
* removes labels entirely (and, until now set, means the overlay is never
|
|
1623
|
+
* even constructed).
|
|
1624
|
+
*/
|
|
1625
|
+
setLabelProperty(property) {
|
|
1626
|
+
this._labelProperty = property;
|
|
1627
|
+
if (!this.mapReady) {
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
if (!property) {
|
|
1631
|
+
this._labelsOverlay?.destroy();
|
|
1632
|
+
this._labelsOverlay = undefined;
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
this._ensureLabelsOverlay();
|
|
1636
|
+
this._labelsOverlay?.refresh();
|
|
1637
|
+
}
|
|
1638
|
+
_ensureLabelsOverlay() {
|
|
1639
|
+
this._assertInitialized();
|
|
1640
|
+
if (this._labelsOverlay) {
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
this._labelsOverlay = new MapFeatureLabelsOverlay(() => this._buildLabels());
|
|
1644
|
+
this._labelsOverlay.setMap(this.googleMap);
|
|
1645
|
+
}
|
|
1646
|
+
_buildLabels() {
|
|
1647
|
+
if (this.isDrawing()) {
|
|
1648
|
+
return [];
|
|
1649
|
+
}
|
|
1650
|
+
const property = this._labelProperty;
|
|
1651
|
+
if (!property) {
|
|
1652
|
+
return [];
|
|
1653
|
+
}
|
|
699
1654
|
this._assertInitialized();
|
|
700
|
-
|
|
1655
|
+
const byKey = new Map();
|
|
1656
|
+
this.googleMap.data.forEach((feature) => {
|
|
1657
|
+
const key = this._registry.keyOf(feature);
|
|
1658
|
+
const raw = feature.getProperty(property);
|
|
1659
|
+
const text = raw === null || raw === undefined || raw === '' ? '' : String(raw);
|
|
1660
|
+
const existing = byKey.get(key);
|
|
1661
|
+
const bounds = existing?.bounds ?? new google.maps.LatLngBounds();
|
|
1662
|
+
feature.getGeometry()?.forEachLatLng((latLng) => bounds.extend(latLng));
|
|
1663
|
+
if (!existing) {
|
|
1664
|
+
byKey.set(key, { text, bounds, others: new Set(text ? [text] : []) });
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
if (text) {
|
|
1668
|
+
existing.others.add(text);
|
|
1669
|
+
if (!existing.text) {
|
|
1670
|
+
existing.text = text;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
});
|
|
1674
|
+
const labels = [];
|
|
1675
|
+
for (const [key, entry] of byKey) {
|
|
1676
|
+
if (entry.others.size > 1) {
|
|
1677
|
+
this._warnAboutLabelDisagreement(key);
|
|
1678
|
+
}
|
|
1679
|
+
if (entry.text) {
|
|
1680
|
+
labels.push({ key, text: entry.text, bounds: entry.bounds });
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
return labels;
|
|
1684
|
+
}
|
|
1685
|
+
_warnAboutLabelDisagreement(key) {
|
|
1686
|
+
if (this._warnedAboutLabelDisagreement ||
|
|
1687
|
+
(typeof ngDevMode !== 'undefined' && !ngDevMode)) {
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
this._warnedAboutLabelDisagreement = true;
|
|
1691
|
+
console.warn(`[seam-google-maps] features in group "${key}" carry different ` +
|
|
1692
|
+
`"${this._labelProperty}" values. One is rendered; which is ` +
|
|
1693
|
+
`unspecified. Keeping them consistent is the consumer's business.`);
|
|
1694
|
+
}
|
|
1695
|
+
isEditMode() {
|
|
1696
|
+
return this._editModeSubject.value;
|
|
1697
|
+
}
|
|
1698
|
+
setEditMode(enabled) {
|
|
1699
|
+
if (this._model.id !== 'grouped' || enabled === this.isEditMode()) {
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
if (!enabled) {
|
|
1703
|
+
const wasDrawing = this.isDrawing();
|
|
1704
|
+
this.stopDrawing();
|
|
1705
|
+
if (wasDrawing) {
|
|
1706
|
+
this._reapplyCurrentSelection();
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
this._editModeSubject.next(enabled);
|
|
1710
|
+
this._refreshStyles();
|
|
1711
|
+
// Arm drawing immediately when edit mode is turned on with nothing
|
|
1712
|
+
// selected, so the button press itself is what puts Terra Draw in
|
|
1713
|
+
// crosshair mode — matching legacy's button, which calls startDrawing()
|
|
1714
|
+
// directly. Without this, the very next click only arms Terra Draw
|
|
1715
|
+
// (switching the cursor) without placing a vertex, and a SECOND click is
|
|
1716
|
+
// what actually starts the polygon.
|
|
701
1717
|
//
|
|
702
|
-
//
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
1718
|
+
// Not when a group is already selected: the user is there to reshape that
|
|
1719
|
+
// group by clicking its vertex/midpoint handles, and arming would make
|
|
1720
|
+
// every click place a vertex instead — including the clicks on OTHER
|
|
1721
|
+
// groups that let the user switch which one is selected. Also not on the
|
|
1722
|
+
// selection merely being CLEARED while already in edit mode (e.g. via
|
|
1723
|
+
// Escape, in handleEscape()): that path never calls setEditMode(), so it
|
|
1724
|
+
// never reaches this arm — deliberately, since that state has to stay
|
|
1725
|
+
// clickable or Escape becomes a trap with no way back to selecting.
|
|
1726
|
+
//
|
|
1727
|
+
// Goes through startDrawing(), the same path a click on open map already
|
|
1728
|
+
// uses, so a Terra Draw recreate still in flight from the PREVIOUS
|
|
1729
|
+
// session (stopDrawing() now kicks it off eagerly — see
|
|
1730
|
+
// _hasPlacedVertex()) is honoured rather than bypassed — a call that
|
|
1731
|
+
// arrives before the new instance's `ready` fires queues itself via
|
|
1732
|
+
// _pendingStartDrawing exactly as it would from that click.
|
|
1733
|
+
if (enabled && this._selectionSubject.value === null) {
|
|
1734
|
+
this.startDrawing();
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
/**
|
|
1738
|
+
* Re-run the style callback for every feature.
|
|
1739
|
+
*
|
|
1740
|
+
* Google re-evaluates the callback whenever the style is set, so handing
|
|
1741
|
+
* back the same stored function is enough to repaint after a mode change.
|
|
1742
|
+
*/
|
|
1743
|
+
_refreshStyles() {
|
|
1744
|
+
if (!this.mapReady || !this._styleFn) {
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
this._assertInitialized();
|
|
1748
|
+
this.googleMap.data.setStyle(this._styleFn);
|
|
1749
|
+
}
|
|
1750
|
+
get _registry() {
|
|
1751
|
+
this._assertInitialized();
|
|
1752
|
+
if (!this._groups) {
|
|
1753
|
+
this._groups = new FeatureGroupRegistry(this.googleMap.data, this._groupOptions);
|
|
1754
|
+
}
|
|
1755
|
+
return this._groups;
|
|
1756
|
+
}
|
|
1757
|
+
_interactionContext() {
|
|
1758
|
+
return {
|
|
1759
|
+
groups: this._registry,
|
|
1760
|
+
editingEnabled: this.isEditingEnabled(),
|
|
1761
|
+
allowHoles: this._allowDrawingHoleInPolygon,
|
|
1762
|
+
editMode: this.isEditMode(),
|
|
1763
|
+
isDrawing: this.isDrawing(),
|
|
1764
|
+
getSelectedKey: () => this._selectionSubject.value?.group.key ?? null,
|
|
1765
|
+
selectGroup: (key, feature) => this._applySelection(key, feature),
|
|
1766
|
+
startDrawing: () => this.startDrawing(),
|
|
1767
|
+
findContainingFeature: (polygon, groupKey, accept) => this._findContainingFeature(polygon, groupKey, accept),
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
/**
|
|
1771
|
+
* Find an existing feature that fully contains `polygon`, restricted to a
|
|
1772
|
+
* group when `groupKey` is given and to features `accept` returns true for
|
|
1773
|
+
* when given. Matches any part of a MultiPolygon.
|
|
1774
|
+
*
|
|
1775
|
+
* `accept` is applied DURING iteration — a rejected candidate is skipped,
|
|
1776
|
+
* not treated as ending the search — so legacy mode's Polygon-only filter
|
|
1777
|
+
* can let a later Polygon candidate still match.
|
|
1778
|
+
*/
|
|
1779
|
+
_findContainingFeature(polygon, groupKey, accept) {
|
|
1780
|
+
this._assertInitialized();
|
|
1781
|
+
let match;
|
|
1782
|
+
this.googleMap.data.forEach((feature) => {
|
|
1783
|
+
if (match) {
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
if (groupKey !== undefined &&
|
|
1787
|
+
this._registry.keyOf(feature) !== groupKey) {
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1790
|
+
if (accept && !accept(feature)) {
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
const contains = polygonsFromDataFeature(feature).some((part) => polygonContains(part, polygon));
|
|
1794
|
+
if (contains) {
|
|
1795
|
+
match = feature;
|
|
1796
|
+
}
|
|
708
1797
|
});
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
1798
|
+
return match;
|
|
1799
|
+
}
|
|
1800
|
+
_applySelection(key, feature) {
|
|
1801
|
+
this._assertInitialized();
|
|
1802
|
+
this._focusedFeature = feature;
|
|
1803
|
+
const selectedFeatures = key === null ? [] : this._registry.featuresIn(key);
|
|
1804
|
+
this.googleMap.data.forEach((f) => {
|
|
1805
|
+
const shouldSelect = selectedFeatures.indexOf(f) !== -1;
|
|
1806
|
+
if (isFeatureSelected(f) !== shouldSelect) {
|
|
1807
|
+
setFeatureSelected(f, shouldSelect);
|
|
1808
|
+
}
|
|
1809
|
+
});
|
|
1810
|
+
if (key === null) {
|
|
1811
|
+
this._selectionSubject.next(null);
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
const resolved = this._registry.groupWithSources(key);
|
|
1815
|
+
if (!resolved) {
|
|
1816
|
+
this._selectionSubject.next(null);
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
this._selectionSubject.next(this._targetFor(resolved, feature));
|
|
1820
|
+
}
|
|
1821
|
+
/**
|
|
1822
|
+
* Pair a group with the emitted GeoJSON for one of its `Data.Feature`s.
|
|
1823
|
+
*
|
|
1824
|
+
* Indexes into the returned source array rather than `featuresIn()`, because
|
|
1825
|
+
* a feature with unsupported geometry is dropped from the emitted list and
|
|
1826
|
+
* would shift every index after it.
|
|
1827
|
+
*/
|
|
1828
|
+
_targetFor(resolved, feature) {
|
|
1829
|
+
const index = feature ? resolved.sources.indexOf(feature) : -1;
|
|
1830
|
+
return {
|
|
1831
|
+
group: resolved.group,
|
|
1832
|
+
feature: index === -1 ? null : resolved.group.features[index],
|
|
1833
|
+
};
|
|
1834
|
+
}
|
|
1835
|
+
selectGroup(key) {
|
|
1836
|
+
this._assertInitialized();
|
|
1837
|
+
if (key === null) {
|
|
1838
|
+
this._applySelection(null, null);
|
|
1839
|
+
return true;
|
|
1840
|
+
}
|
|
1841
|
+
const features = this._registry.featuresIn(key);
|
|
1842
|
+
if (features.length === 0) {
|
|
1843
|
+
return false;
|
|
1844
|
+
}
|
|
1845
|
+
this._applySelection(key, features[0]);
|
|
1846
|
+
return true;
|
|
1847
|
+
}
|
|
1848
|
+
clearSelection() {
|
|
1849
|
+
this._applySelection(null, null);
|
|
1850
|
+
}
|
|
1851
|
+
getGroups() {
|
|
1852
|
+
return this._registry.groups();
|
|
1853
|
+
}
|
|
1854
|
+
_boundsForGroup(key) {
|
|
1855
|
+
const features = this._registry.featuresIn(key);
|
|
1856
|
+
if (features.length === 0) {
|
|
1857
|
+
return undefined;
|
|
1858
|
+
}
|
|
1859
|
+
const bounds = new google.maps.LatLngBounds();
|
|
1860
|
+
features.forEach((f) => f.getGeometry()?.forEachLatLng((latLng) => bounds.extend(latLng)));
|
|
1861
|
+
return bounds;
|
|
1862
|
+
}
|
|
1863
|
+
fitGroup(key, padding) {
|
|
1864
|
+
this._assertInitialized();
|
|
1865
|
+
const bounds = this._boundsForGroup(key);
|
|
1866
|
+
if (!bounds) {
|
|
1867
|
+
return false;
|
|
1868
|
+
}
|
|
1869
|
+
this.googleMap.fitBounds(bounds, padding ?? this._padding);
|
|
1870
|
+
return true;
|
|
1871
|
+
}
|
|
1872
|
+
panToGroup(key) {
|
|
1873
|
+
this._assertInitialized();
|
|
1874
|
+
const bounds = this._boundsForGroup(key);
|
|
1875
|
+
if (!bounds) {
|
|
1876
|
+
return false;
|
|
1877
|
+
}
|
|
1878
|
+
this.googleMap.panTo(bounds.getCenter());
|
|
1879
|
+
return true;
|
|
1880
|
+
}
|
|
1881
|
+
/**
|
|
1882
|
+
* Delete only the polygon the last interaction landed on.
|
|
1883
|
+
*
|
|
1884
|
+
* Re-applies the selection's group key afterward (with no focused feature)
|
|
1885
|
+
* rather than leaving `selection$` holding a `TheSeamMapGroupTarget` whose
|
|
1886
|
+
* `feature` no longer exists — `_applySelection` naturally clears to null
|
|
1887
|
+
* when the group is now empty, via `groupWithSources`.
|
|
1888
|
+
*/
|
|
1889
|
+
deleteFocusedFeature() {
|
|
1890
|
+
this._assertInitialized();
|
|
1891
|
+
const key = this._focusedFeature
|
|
1892
|
+
? this._registry.keyOf(this._focusedFeature)
|
|
1893
|
+
: (this._selectionSubject.value?.group.key ?? null);
|
|
1894
|
+
const contextMenuTargetInGroup = key !== null && this._contextMenuTargetSubject.value?.group.key === key;
|
|
1895
|
+
if (this._focusedFeature) {
|
|
1896
|
+
this.googleMap.data.remove(this._focusedFeature);
|
|
1897
|
+
this._focusedFeature = null;
|
|
1898
|
+
}
|
|
1899
|
+
else {
|
|
1900
|
+
this.deleteSelection();
|
|
1901
|
+
}
|
|
1902
|
+
this._applySelection(key, null);
|
|
1903
|
+
// Same reasoning as deleteGroup(): a context-menu target naming this
|
|
1904
|
+
// group may now reference a removed feature (or a stale feature count),
|
|
1905
|
+
// so clear it — but only when it actually named this group.
|
|
1906
|
+
if (contextMenuTargetInGroup) {
|
|
1907
|
+
this._contextMenuTargetSubject.next(null);
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
/** Escape cascades: cancel a draw, then clear selection, then leave edit mode. */
|
|
1911
|
+
handleEscape() {
|
|
1912
|
+
if (this.isDrawing()) {
|
|
1913
|
+
this.stopDrawing();
|
|
1914
|
+
this._reapplyCurrentSelection();
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
if (this._selectionSubject.value !== null) {
|
|
1918
|
+
this.clearSelection();
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
if (this.isEditMode()) {
|
|
1922
|
+
this.setEditMode(false);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
/**
|
|
1926
|
+
* `startDrawing()` raw-deselects every feature (via `setFeatureSelected`)
|
|
1927
|
+
* without touching `_selectionSubject`, so that `onDrawFinished` can still
|
|
1928
|
+
* read `getSelectedKey()` for the group being drawn into. When a draw ends
|
|
1929
|
+
* WITHOUT producing a finished feature — cancelled by `Escape` or
|
|
1930
|
+
* `setEditMode(false)`, or a `finish` event with no valid geometry — nothing
|
|
1931
|
+
* else re-applies those raw flags, so the map renders the selection as gone
|
|
1932
|
+
* while `selection$`/`getSelectedKey()` still report it. Call this at every
|
|
1933
|
+
* such stopping point to bring the two back in sync.
|
|
1934
|
+
*
|
|
1935
|
+
* Not called from the successful-finish path in `_onDrawFinished`: that
|
|
1936
|
+
* path calls `_applySelection` itself with the new/joined selection, and
|
|
1937
|
+
* doing it here first would only add a redundant, momentarily-stale
|
|
1938
|
+
* `selectionChange` emission ahead of the real one.
|
|
1939
|
+
*/
|
|
1940
|
+
_reapplyCurrentSelection() {
|
|
1941
|
+
if (this._model.id !== 'grouped') {
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
const currentKey = this._selectionSubject.value?.group.key ?? null;
|
|
1945
|
+
this._applySelection(currentKey, this._focusedFeature);
|
|
1946
|
+
}
|
|
1947
|
+
_initFeatureStyling() {
|
|
1948
|
+
this._assertInitialized();
|
|
1949
|
+
this.googleMap.addListener('click', () => {
|
|
1950
|
+
// The physical click that closes a polygon can reach this listener
|
|
1951
|
+
// TWICE: once (fast) via Terra Draw's own pointer-driven close
|
|
1952
|
+
// detection — which is what runs `_onDrawFinished()` / `stopDrawing()`
|
|
1953
|
+
// and flips `isDrawing()` false — and, separately, via Google's own
|
|
1954
|
+
// `click` recognition on this map, which this listener is bound to
|
|
1955
|
+
// directly. By the time that second delivery arrives (confirmed
|
|
1956
|
+
// against the live Storybook: consistently a few milliseconds later,
|
|
1957
|
+
// comfortably within one animation frame — see
|
|
1958
|
+
// .superpowers/closing-click-report.md), `isDrawing()` already reads
|
|
1959
|
+
// false, so the guard just below cannot tell it apart from a fresh
|
|
1960
|
+
// click on open map. `_suppressNextMapClick` exists to catch exactly
|
|
1961
|
+
// that echo; see its doc comment for why a `domEvent.timeStamp`
|
|
1962
|
+
// comparison does NOT work here.
|
|
1963
|
+
if (this._suppressNextMapClick) {
|
|
1964
|
+
this._suppressNextMapClick = false;
|
|
1965
|
+
return;
|
|
718
1966
|
}
|
|
719
|
-
|
|
1967
|
+
// While drawing, a click on open map is placing a vertex, not a map
|
|
1968
|
+
// click — mirrors the data 'click' listener's guard just below. Without
|
|
1969
|
+
// this, `onMapClick` in 'grouped' mode calls `startDrawing()`, which
|
|
1970
|
+
// calls `setMode('polyline')` again and resets the in-progress path.
|
|
1971
|
+
if (this.isDrawing()) {
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
this._model.onMapClick(this._interactionContext());
|
|
1975
|
+
});
|
|
1976
|
+
this._styleFn = (feature) => computeFeatureStyle(feature, {
|
|
1977
|
+
editingEnabled: this.isEditingEnabled(),
|
|
1978
|
+
...this._model.featureFlags(feature, this._interactionContext()),
|
|
720
1979
|
});
|
|
721
|
-
|
|
1980
|
+
this.googleMap.data.setStyle(this._styleFn);
|
|
722
1981
|
this.googleMap.data.addListener('click', (event) => {
|
|
723
|
-
|
|
724
|
-
// While drawing, a click on an existing polygon is placing a vertex,
|
|
725
|
-
// not selecting a feature. Selecting here would both steal the click
|
|
726
|
-
// and leave a misleading selection (see startDrawing's deselect).
|
|
1982
|
+
// While drawing, a click on a polygon is placing a vertex.
|
|
727
1983
|
if (this.isDrawing()) {
|
|
728
1984
|
return;
|
|
729
1985
|
}
|
|
730
|
-
|
|
731
|
-
this.googleMap.data.forEach((f) => {
|
|
732
|
-
if (f !== event.feature && isFeatureSelected(f)) {
|
|
733
|
-
setFeatureSelected(f, false);
|
|
734
|
-
}
|
|
735
|
-
});
|
|
1986
|
+
this._model.onFeatureClick(event.feature, this._interactionContext());
|
|
736
1987
|
});
|
|
737
|
-
// Set a style on hovered features that can be selected.
|
|
738
1988
|
this.googleMap.data.addListener('mouseover', (event) => {
|
|
739
1989
|
this._assertInitialized();
|
|
740
1990
|
this.googleMap.data.revertStyle();
|
|
741
1991
|
if (!this.isDrawing() && !isFeatureSelected(event.feature)) {
|
|
742
1992
|
this.setFeatureHoveredStyleOverride(event.feature);
|
|
743
1993
|
}
|
|
1994
|
+
const resolved = this._registry.groupWithSources(this._registry.keyOf(event.feature));
|
|
1995
|
+
this._hoverSubject.next(resolved ? this._targetFor(resolved, event.feature) : null);
|
|
744
1996
|
});
|
|
745
|
-
|
|
746
|
-
this.googleMap.data.addListener('mouseout', (event) => {
|
|
1997
|
+
this.googleMap.data.addListener('mouseout', () => {
|
|
747
1998
|
this._assertInitialized();
|
|
748
1999
|
this.googleMap.data.revertStyle();
|
|
2000
|
+
this._hoverSubject.next(null);
|
|
749
2001
|
});
|
|
750
2002
|
}
|
|
751
2003
|
setFeatureHoveredStyleOverride(feature) {
|
|
752
2004
|
this._assertInitialized();
|
|
753
|
-
|
|
754
|
-
const hoverOptions = getHoveredStyleOptionsDefinedByFeature(feature);
|
|
755
|
-
this._mergeStyleOptions(overrideOpts, hoverOptions ?? {});
|
|
756
|
-
this.googleMap.data.overrideStyle(feature, overrideOpts);
|
|
757
|
-
}
|
|
758
|
-
_mergeStyleOptions(options, propertiesStyleOptions) {
|
|
759
|
-
if (Object.keys(propertiesStyleOptions).length === 0) {
|
|
760
|
-
return;
|
|
761
|
-
}
|
|
762
|
-
for (const opt of SUPPORTED_PROPERTY_STYLE_OPTIONS) {
|
|
763
|
-
if (Object.prototype.hasOwnProperty.call(propertiesStyleOptions, opt)) {
|
|
764
|
-
options[opt] = propertiesStyleOptions[opt];
|
|
765
|
-
}
|
|
766
|
-
}
|
|
2005
|
+
this.googleMap.data.overrideStyle(feature, computeFeatureHoverStyle(feature));
|
|
767
2006
|
}
|
|
768
2007
|
_initFeatureChangeListeners() {
|
|
769
2008
|
this._assertInitialized();
|
|
770
2009
|
createFeatureChangeObservable(this.googleMap.data, this._ngZone)
|
|
771
|
-
.pipe(switchMap(() => from(this.getGeoJson()).pipe(tap((geoJson) =>
|
|
2010
|
+
.pipe(switchMap(() => from(this.getGeoJson()).pipe(tap((geoJson) => {
|
|
2011
|
+
this._mapValueManager.setValue(geoJson, MapValueSource.FeatureChange);
|
|
2012
|
+
this._labelsOverlay?.refresh();
|
|
2013
|
+
}))), takeUntil(this._ngUnsubscribe))
|
|
772
2014
|
.subscribe();
|
|
773
2015
|
this.googleMap.data.addListener('contextmenu', (event) => {
|
|
774
|
-
if (!
|
|
2016
|
+
if (!this._model.allowsContextMenu(event.feature, this._interactionContext())) {
|
|
775
2017
|
return;
|
|
776
2018
|
}
|
|
2019
|
+
this._setContextMenuTarget(event.feature);
|
|
777
2020
|
this._openContextMenuForFeature(event.feature, event.latLng ?? undefined);
|
|
778
2021
|
});
|
|
779
2022
|
}
|
|
2023
|
+
/**
|
|
2024
|
+
* Establish `feature` as what the context menu is about to open for:
|
|
2025
|
+
* `_focusedFeature` (what "Delete Polygon" acts on) and
|
|
2026
|
+
* `contextMenuTarget$` (what "Delete Field" is gated on and acts on) both
|
|
2027
|
+
* follow it. Shared by the `contextmenu` mouse listener and
|
|
2028
|
+
* `openContextMenu()`'s keyboard path so the two establish the target
|
|
2029
|
+
* identically — the menu's target must always be the feature the menu was
|
|
2030
|
+
* opened for, on either path. Before this, only the mouse listener set
|
|
2031
|
+
* `contextMenuTarget$`, so pressing the `ContextMenu` key could open a menu
|
|
2032
|
+
* over whatever was CURRENTLY selected while still offering "Delete Field"
|
|
2033
|
+
* for whatever a PRIOR right-click had targeted.
|
|
2034
|
+
*/
|
|
2035
|
+
_setContextMenuTarget(feature) {
|
|
2036
|
+
this._focusedFeature = feature;
|
|
2037
|
+
const resolved = this._registry.groupWithSources(this._registry.keyOf(feature));
|
|
2038
|
+
this._contextMenuTargetSubject.next(resolved ? this._targetFor(resolved, feature) : null);
|
|
2039
|
+
}
|
|
780
2040
|
// TODO: Refactor out of the service meant to just wrap the google maps api.
|
|
781
2041
|
hasSelectedFeature() {
|
|
782
2042
|
this._assertInitialized();
|
|
@@ -802,7 +2062,9 @@ class GoogleMapsService {
|
|
|
802
2062
|
// TODO: Refactor out of the service meant to just wrap the google maps api.
|
|
803
2063
|
openContextMenu() {
|
|
804
2064
|
const feature = this.getSelectedFeature();
|
|
805
|
-
if (feature
|
|
2065
|
+
if (feature &&
|
|
2066
|
+
this._model.allowsContextMenu(feature, this._interactionContext())) {
|
|
2067
|
+
this._setContextMenuTarget(feature);
|
|
806
2068
|
this._openContextMenuForFeature(feature);
|
|
807
2069
|
}
|
|
808
2070
|
}
|
|
@@ -843,34 +2105,62 @@ class GoogleMapsService {
|
|
|
843
2105
|
'Please wait for the API to load before trying to interact with it.');
|
|
844
2106
|
}
|
|
845
2107
|
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Arm `_suppressNextMapClick`, and guarantee it cannot linger forever if
|
|
2110
|
+
* the echo it exists for never arrives — confirmed to be the normal case
|
|
2111
|
+
* for most real closes, and true of every synthetic/`play()`-driven draw,
|
|
2112
|
+
* per .superpowers/closing-click-report.md. A `setTimeout` would "solve"
|
|
2113
|
+
* this by guessing a safe wall-clock delay, which is exactly the kind of
|
|
2114
|
+
* timing window this fix is trying to avoid introducing. Two
|
|
2115
|
+
* `requestAnimationFrame` callbacks bound the window instead: the observed
|
|
2116
|
+
* echo arrives a few milliseconds after `stopDrawing()`, comfortably
|
|
2117
|
+
* inside a single frame, so two frames is generous headroom without
|
|
2118
|
+
* guessing at a duration — and two real user actions (even fast automated
|
|
2119
|
+
* ones) are never going to land within two animation frames of each other,
|
|
2120
|
+
* so an unrelated later click is never at risk of being swallowed.
|
|
2121
|
+
*/
|
|
2122
|
+
_armMapClickSuppression() {
|
|
2123
|
+
this._suppressNextMapClick = true;
|
|
2124
|
+
requestAnimationFrame(() => {
|
|
2125
|
+
requestAnimationFrame(() => {
|
|
2126
|
+
this._suppressNextMapClick = false;
|
|
2127
|
+
});
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
846
2130
|
_onDrawFinished(id) {
|
|
2131
|
+
// Armed first, before any other work below — including the early return
|
|
2132
|
+
// for a draw that produced no usable geometry — since the closing
|
|
2133
|
+
// click's late echo (see `_suppressNextMapClick`'s doc comment) can
|
|
2134
|
+
// follow either outcome.
|
|
2135
|
+
this._armMapClickSuppression();
|
|
847
2136
|
const feature = this._terraDraw?.getSnapshotFeature(id);
|
|
848
2137
|
this._terraDraw?.removeFeatures([id]);
|
|
849
2138
|
this.stopDrawing();
|
|
850
2139
|
const drawn = feature ? this._toDrawnPolygon(feature.geometry) : undefined;
|
|
851
2140
|
if (!drawn || !polygonHasMinDistinctVertices(drawn, 3)) {
|
|
2141
|
+
// A 'finish' event that produced no usable geometry is, for selection
|
|
2142
|
+
// purposes, a cancelled draw — see _reapplyCurrentSelection().
|
|
2143
|
+
this._reapplyCurrentSelection();
|
|
852
2144
|
return;
|
|
853
2145
|
}
|
|
854
2146
|
this._assertInitialized();
|
|
855
|
-
const
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
const merged = addHoleToPolygon(exteriorPolygon, drawn);
|
|
862
|
-
// Mutate the EXISTING feature instance to preserve its identity and
|
|
863
|
-
// properties (see design constraints).
|
|
864
|
-
exteriorFeature.setGeometry(dataPolygonFromGeoJson(merged));
|
|
865
|
-
setFeatureSelected(exteriorFeature, true);
|
|
866
|
-
return;
|
|
867
|
-
}
|
|
2147
|
+
const context = this._interactionContext();
|
|
2148
|
+
const outcome = this._model.onDrawFinished(drawn, context);
|
|
2149
|
+
if (outcome.kind === 'hole' && applyHoleToFeature(outcome.target, drawn)) {
|
|
2150
|
+
this._applySelection(this._registry.keyOf(outcome.target), outcome.target);
|
|
2151
|
+
this._labelsOverlay?.refresh();
|
|
2152
|
+
return;
|
|
868
2153
|
}
|
|
869
2154
|
const newFeature = new google.maps.Data.Feature({
|
|
870
2155
|
geometry: dataPolygonFromGeoJson(drawn),
|
|
871
2156
|
});
|
|
872
2157
|
this.googleMap.data.add(newFeature);
|
|
873
|
-
|
|
2158
|
+
const key = outcome.kind === 'newFeature' && outcome.groupKey !== null
|
|
2159
|
+
? (this._registry.assignKey(newFeature, outcome.groupKey),
|
|
2160
|
+
outcome.groupKey)
|
|
2161
|
+
: this._registry.assignNewKey(newFeature);
|
|
2162
|
+
this._applySelection(key, newFeature);
|
|
2163
|
+
this._labelsOverlay?.refresh();
|
|
874
2164
|
}
|
|
875
2165
|
/**
|
|
876
2166
|
* Normalize a finished Terra Draw geometry into a Polygon. A Polygon (the
|
|
@@ -892,25 +2182,6 @@ class GoogleMapsService {
|
|
|
892
2182
|
}
|
|
893
2183
|
return undefined;
|
|
894
2184
|
}
|
|
895
|
-
/**
|
|
896
|
-
* Find an existing Polygon feature that fully contains the drawn polygon, so
|
|
897
|
-
* the drawing can be applied as a cutout. Returns the existing feature
|
|
898
|
-
* instance (never a copy).
|
|
899
|
-
*/
|
|
900
|
-
_getPossibleExteriorFeature(drawn) {
|
|
901
|
-
this._assertInitialized();
|
|
902
|
-
let match;
|
|
903
|
-
this.googleMap.data.forEach((f) => {
|
|
904
|
-
if (match) {
|
|
905
|
-
return;
|
|
906
|
-
}
|
|
907
|
-
const candidate = geoJsonPolygonFromDataFeature(f);
|
|
908
|
-
if (candidate && polygonContains(candidate, drawn)) {
|
|
909
|
-
match = f;
|
|
910
|
-
}
|
|
911
|
-
});
|
|
912
|
-
return match;
|
|
913
|
-
}
|
|
914
2185
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: GoogleMapsService, deps: [{ token: MapValueManagerService }, { token: i0.NgZone }, { token: i0.ViewContainerRef }], target: i0.ɵɵFactoryTarget.Injectable });
|
|
915
2186
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: GoogleMapsService });
|
|
916
2187
|
}
|
|
@@ -997,12 +2268,31 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImpo
|
|
|
997
2268
|
class TheSeamGoogleMapsDrawButtonControlComponent {
|
|
998
2269
|
_googleMaps = inject(GoogleMapsService);
|
|
999
2270
|
_data = inject(MAP_CONTROL_DATA, { optional: true });
|
|
1000
|
-
|
|
2271
|
+
_mode = toSignal(this._googleMaps.interactionMode$, {
|
|
2272
|
+
initialValue: 'legacy',
|
|
2273
|
+
});
|
|
2274
|
+
_drawing = toSignal(this._googleMaps.drawing$, {
|
|
1001
2275
|
initialValue: false,
|
|
1002
2276
|
});
|
|
1003
|
-
|
|
2277
|
+
_editMode = toSignal(this._googleMaps.editMode$, {
|
|
2278
|
+
initialValue: false,
|
|
2279
|
+
});
|
|
2280
|
+
/**
|
|
2281
|
+
* In 'legacy' the button toggles drawing directly. In 'grouped' it toggles
|
|
2282
|
+
* edit mode, which is what makes a click on open map begin a drawing.
|
|
2283
|
+
*/
|
|
2284
|
+
_active = computed(() => this._mode() === 'grouped' ? this._editMode() : this._drawing(), ...(ngDevMode ? [{ debugName: "_active" }] : []));
|
|
2285
|
+
_label = computed(() => this._data?.label ??
|
|
2286
|
+
(this._mode() === 'grouped' ? 'Edit Fields' : 'Draw Field'), ...(ngDevMode ? [{ debugName: "_label" }] : []));
|
|
2287
|
+
get label() {
|
|
2288
|
+
return this._label();
|
|
2289
|
+
}
|
|
1004
2290
|
icon = this._data?.icon ?? faDrawPolygon;
|
|
1005
2291
|
_onClick() {
|
|
2292
|
+
if (this._mode() === 'grouped') {
|
|
2293
|
+
this._googleMaps.setEditMode(!this._googleMaps.isEditMode());
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
1006
2296
|
if (this._googleMaps.isDrawing()) {
|
|
1007
2297
|
this._googleMaps.stopDrawing();
|
|
1008
2298
|
}
|
|
@@ -1423,6 +2713,7 @@ class TheSeamGoogleMapsComponent extends _TheSeamGoogleMapsMixinBase {
|
|
|
1423
2713
|
static ngAcceptInputType_streetViewControlEnabled;
|
|
1424
2714
|
static ngAcceptInputType_allowDrawingHoleInPolygon;
|
|
1425
2715
|
static ngAcceptInputType_editingEnabled;
|
|
2716
|
+
static ngAcceptInputType_selectedGroupKey;
|
|
1426
2717
|
_changeDetectorRef = inject(ChangeDetectorRef);
|
|
1427
2718
|
_ngUnsubscribe = new Subject();
|
|
1428
2719
|
_gmApiLoaded;
|
|
@@ -1482,7 +2773,32 @@ class TheSeamGoogleMapsComponent extends _TheSeamGoogleMapsMixinBase {
|
|
|
1482
2773
|
longitude = -98.570209;
|
|
1483
2774
|
latitude = 37.633814;
|
|
1484
2775
|
padding = 0;
|
|
2776
|
+
/**
|
|
2777
|
+
* Which interaction model the map uses.
|
|
2778
|
+
*
|
|
2779
|
+
* `'legacy'` is the single-boundary behaviour this component has always had
|
|
2780
|
+
* and is the default, so existing consumers are unaffected. `'grouped'`
|
|
2781
|
+
* separates selection from geometry editing; see the design doc.
|
|
2782
|
+
*/
|
|
2783
|
+
interactionMode = 'legacy';
|
|
2784
|
+
/**
|
|
2785
|
+
* Name of the GeoJSON property that groups features into one logical thing,
|
|
2786
|
+
* such as a field. Features sharing a value select, style, and edit together.
|
|
2787
|
+
* Unset means every feature is its own group.
|
|
2788
|
+
*/
|
|
2789
|
+
featureGroupProperty;
|
|
2790
|
+
/** Name of the GeoJSON property holding a group's label text. */
|
|
2791
|
+
featureLabelProperty;
|
|
2792
|
+
/**
|
|
2793
|
+
* Generates the key written to `featureGroupProperty` for a newly drawn
|
|
2794
|
+
* group. Consumer-supplied so the format is one the app recognises.
|
|
2795
|
+
*/
|
|
2796
|
+
newGroupKeyFactory;
|
|
2797
|
+
/** Preselect a group. Applied on map-ready and after each external value write. */
|
|
2798
|
+
selectedGroupKey = null;
|
|
1485
2799
|
mapReady = new EventEmitter();
|
|
2800
|
+
selectionChange = new EventEmitter();
|
|
2801
|
+
featureHoverChange = new EventEmitter();
|
|
1486
2802
|
featureContextMenu;
|
|
1487
2803
|
_options = {
|
|
1488
2804
|
mapTypeControl: true,
|
|
@@ -1517,17 +2833,55 @@ class TheSeamGoogleMapsComponent extends _TheSeamGoogleMapsMixinBase {
|
|
|
1517
2833
|
if (this._googleMaps.mapReady &&
|
|
1518
2834
|
changed.source !== MapValueSource.FeatureChange) {
|
|
1519
2835
|
this._googleMaps.setData(changed.value);
|
|
2836
|
+
this._applySelectedGroupKey();
|
|
1520
2837
|
}
|
|
1521
2838
|
}), takeUntil(this._ngUnsubscribe))
|
|
1522
2839
|
.subscribe();
|
|
1523
|
-
|
|
2840
|
+
// `skip(1)` drops the value these BehaviorSubject-backed streams replay on
|
|
2841
|
+
// subscribe. An Angular `@Output` should fire when something changes, not
|
|
2842
|
+
// when someone starts listening — without this, a consumer binding
|
|
2843
|
+
// `(selectionChange)` receives a `null` before the user has interacted at
|
|
2844
|
+
// all, which reads as "the selection was cleared" and can wrongly reset a
|
|
2845
|
+
// panel or dirty a form on open. The subscription happens in the
|
|
2846
|
+
// constructor, when the replayed value is always the initial `null`, so
|
|
2847
|
+
// nothing real is ever skipped.
|
|
2848
|
+
this._googleMaps.selection$
|
|
2849
|
+
.pipe(skip(1), tap((selection) => this.selectionChange.emit(selection)), takeUntil(this._ngUnsubscribe))
|
|
2850
|
+
.subscribe();
|
|
2851
|
+
this._googleMaps.hover$
|
|
2852
|
+
.pipe(skip(1), tap((hover) => this.featureHoverChange.emit(hover)), takeUntil(this._ngUnsubscribe))
|
|
2853
|
+
.subscribe();
|
|
2854
|
+
this._contextMenuItems$ = combineLatest([
|
|
2855
|
+
this._googleMaps.editingEnabled$,
|
|
2856
|
+
// The RIGHT-CLICKED feature's group. In 'grouped' mode the menu now
|
|
2857
|
+
// only opens when that group is already the selection (see
|
|
2858
|
+
// `GroupedInteractionModel.allowsContextMenu()`), so this and
|
|
2859
|
+
// `selection$` always agree here — but this is still the one that
|
|
2860
|
+
// names what "Delete Field" is conceptually acting on, independent of
|
|
2861
|
+
// that coincidence. See `contextMenuTarget$`'s doc comment.
|
|
2862
|
+
this._googleMaps.contextMenuTarget$,
|
|
2863
|
+
]).pipe(map(([enabled, target]) => {
|
|
1524
2864
|
const items = [];
|
|
1525
|
-
if (enabled) {
|
|
2865
|
+
if (!enabled) {
|
|
2866
|
+
return items;
|
|
2867
|
+
}
|
|
2868
|
+
if (this.interactionMode === 'grouped') {
|
|
1526
2869
|
items.push({
|
|
1527
|
-
label: 'Delete',
|
|
1528
|
-
action: () => this.
|
|
2870
|
+
label: 'Delete Polygon',
|
|
2871
|
+
action: () => this._googleMaps.deleteFocusedFeature(),
|
|
1529
2872
|
});
|
|
2873
|
+
if (target && target.group.features.length > 1) {
|
|
2874
|
+
items.push({
|
|
2875
|
+
label: 'Delete Field',
|
|
2876
|
+
action: () => this._googleMaps.deleteGroup(target.group.key),
|
|
2877
|
+
});
|
|
2878
|
+
}
|
|
2879
|
+
return items;
|
|
1530
2880
|
}
|
|
2881
|
+
items.push({
|
|
2882
|
+
label: 'Delete',
|
|
2883
|
+
action: () => this._onClickDeleteFeature(),
|
|
2884
|
+
});
|
|
1531
2885
|
return items;
|
|
1532
2886
|
}), tap((items) => {
|
|
1533
2887
|
if (items.length === 0) {
|
|
@@ -1547,13 +2901,29 @@ class TheSeamGoogleMapsComponent extends _TheSeamGoogleMapsMixinBase {
|
|
|
1547
2901
|
switch (event.code) {
|
|
1548
2902
|
case 'Delete':
|
|
1549
2903
|
if (this._googleMaps.isEditingEnabled()) {
|
|
1550
|
-
this.
|
|
2904
|
+
if (this.interactionMode === 'grouped') {
|
|
2905
|
+
this._googleMaps.deleteFocusedFeature();
|
|
2906
|
+
}
|
|
2907
|
+
else {
|
|
2908
|
+
this._googleMaps.deleteSelection();
|
|
2909
|
+
}
|
|
1551
2910
|
event.preventDefault();
|
|
1552
2911
|
event.stopPropagation();
|
|
1553
2912
|
}
|
|
1554
2913
|
break;
|
|
1555
2914
|
case 'Escape':
|
|
1556
|
-
this.
|
|
2915
|
+
if (this.interactionMode === 'grouped') {
|
|
2916
|
+
this._googleMaps.handleEscape();
|
|
2917
|
+
}
|
|
2918
|
+
else {
|
|
2919
|
+
// Legacy parity: `handleEscape()`'s cascade also clears a
|
|
2920
|
+
// selection, but in legacy mode clicking a feature populates
|
|
2921
|
+
// the selection, so that would newly deselect it (and drop
|
|
2922
|
+
// its vertex handles) on a key that previously only cancelled
|
|
2923
|
+
// a draw. Two apps depend on legacy behaviour unchanged, so
|
|
2924
|
+
// keep the narrower pre-existing call here.
|
|
2925
|
+
this._googleMaps.stopDrawing();
|
|
2926
|
+
}
|
|
1557
2927
|
event.preventDefault();
|
|
1558
2928
|
event.stopPropagation();
|
|
1559
2929
|
break;
|
|
@@ -1602,6 +2972,40 @@ class TheSeamGoogleMapsComponent extends _TheSeamGoogleMapsMixinBase {
|
|
|
1602
2972
|
if (Object.prototype.hasOwnProperty.call(changes, 'padding')) {
|
|
1603
2973
|
this._googleMaps.setPadding(this.padding);
|
|
1604
2974
|
}
|
|
2975
|
+
if (Object.prototype.hasOwnProperty.call(changes, 'interactionMode')) {
|
|
2976
|
+
this._googleMaps.setInteractionMode(this.interactionMode);
|
|
2977
|
+
}
|
|
2978
|
+
if (Object.prototype.hasOwnProperty.call(changes, 'featureGroupProperty') ||
|
|
2979
|
+
Object.prototype.hasOwnProperty.call(changes, 'newGroupKeyFactory')) {
|
|
2980
|
+
this._googleMaps.setGroupOptions({
|
|
2981
|
+
groupProperty: this.featureGroupProperty,
|
|
2982
|
+
newGroupKeyFactory: this.newGroupKeyFactory,
|
|
2983
|
+
});
|
|
2984
|
+
}
|
|
2985
|
+
if (Object.prototype.hasOwnProperty.call(changes, 'selectedGroupKey')) {
|
|
2986
|
+
this._applySelectedGroupKey();
|
|
2987
|
+
}
|
|
2988
|
+
if (Object.prototype.hasOwnProperty.call(changes, 'featureLabelProperty')) {
|
|
2989
|
+
this._googleMaps.setLabelProperty(this.featureLabelProperty);
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
_applySelectedGroupKey() {
|
|
2993
|
+
if (!this._googleMaps.mapReady) {
|
|
2994
|
+
return;
|
|
2995
|
+
}
|
|
2996
|
+
if (this.selectedGroupKey === null) {
|
|
2997
|
+
this._googleMaps.clearSelection();
|
|
2998
|
+
return;
|
|
2999
|
+
}
|
|
3000
|
+
if (!this._googleMaps.selectGroup(this.selectedGroupKey)) {
|
|
3001
|
+
// The named group is not in the current value. Clearing keeps the map
|
|
3002
|
+
// and the consumer's expectation from silently diverging.
|
|
3003
|
+
this._googleMaps.clearSelection();
|
|
3004
|
+
if (typeof ngDevMode === 'undefined' || ngDevMode) {
|
|
3005
|
+
console.warn(`[seam-google-maps] selectedGroupKey "${this.selectedGroupKey}" ` +
|
|
3006
|
+
`matches no group in the current value.`);
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
1605
3009
|
}
|
|
1606
3010
|
writeValue(value) {
|
|
1607
3011
|
this.value = value;
|
|
@@ -1643,6 +3047,13 @@ class TheSeamGoogleMapsComponent extends _TheSeamGoogleMapsMixinBase {
|
|
|
1643
3047
|
this.idleListener = this._googleMaps.googleMap?.addListener('idle', () => {
|
|
1644
3048
|
this._googleMaps.googleMap?.setZoom(this.zoom);
|
|
1645
3049
|
this._googleMaps.reCenterOnFeatures();
|
|
3050
|
+
this._googleMaps.setInteractionMode(this.interactionMode);
|
|
3051
|
+
this._googleMaps.setGroupOptions({
|
|
3052
|
+
groupProperty: this.featureGroupProperty,
|
|
3053
|
+
newGroupKeyFactory: this.newGroupKeyFactory,
|
|
3054
|
+
});
|
|
3055
|
+
this._googleMaps.setLabelProperty(this.featureLabelProperty);
|
|
3056
|
+
this._applySelectedGroupKey();
|
|
1646
3057
|
this.mapReady.emit(this._googleMaps.googleMap);
|
|
1647
3058
|
this.idleListener?.remove();
|
|
1648
3059
|
});
|
|
@@ -1650,8 +3061,54 @@ class TheSeamGoogleMapsComponent extends _TheSeamGoogleMapsMixinBase {
|
|
|
1650
3061
|
_onClickDeleteFeature() {
|
|
1651
3062
|
this._googleMaps.deleteSelection();
|
|
1652
3063
|
}
|
|
3064
|
+
// The service delegates below guard on `mapReady` themselves, the same way
|
|
3065
|
+
// `_applySelectedGroupKey()` already does for the declarative
|
|
3066
|
+
// `selectedGroupKey` input. Their service-side counterparts call
|
|
3067
|
+
// `_assertInitialized()` and throw when the map hasn't finished loading;
|
|
3068
|
+
// this component's public surface is documented as non-throwing (an
|
|
3069
|
+
// unknown key returns `false` and changes nothing), so "not ready yet"
|
|
3070
|
+
// must behave the same way, not worse.
|
|
3071
|
+
/** Select a group by key. Returns false when no such group exists. */
|
|
3072
|
+
selectGroup(key) {
|
|
3073
|
+
if (!this._googleMaps.mapReady) {
|
|
3074
|
+
return false;
|
|
3075
|
+
}
|
|
3076
|
+
return this._googleMaps.selectGroup(key);
|
|
3077
|
+
}
|
|
3078
|
+
clearSelection() {
|
|
3079
|
+
if (!this._googleMaps.mapReady) {
|
|
3080
|
+
return;
|
|
3081
|
+
}
|
|
3082
|
+
this._googleMaps.clearSelection();
|
|
3083
|
+
}
|
|
3084
|
+
/** Fit the viewport to a group. Returns false when no such group exists. */
|
|
3085
|
+
fitGroup(key, padding) {
|
|
3086
|
+
if (!this._googleMaps.mapReady) {
|
|
3087
|
+
return false;
|
|
3088
|
+
}
|
|
3089
|
+
return this._googleMaps.fitGroup(key, padding);
|
|
3090
|
+
}
|
|
3091
|
+
/** Pan to a group's centre. Returns false when no such group exists. */
|
|
3092
|
+
panToGroup(key) {
|
|
3093
|
+
if (!this._googleMaps.mapReady) {
|
|
3094
|
+
return false;
|
|
3095
|
+
}
|
|
3096
|
+
return this._googleMaps.panToGroup(key);
|
|
3097
|
+
}
|
|
3098
|
+
getGroups() {
|
|
3099
|
+
if (!this._googleMaps.mapReady) {
|
|
3100
|
+
return [];
|
|
3101
|
+
}
|
|
3102
|
+
return this._googleMaps.getGroups();
|
|
3103
|
+
}
|
|
3104
|
+
setEditMode(enabled) {
|
|
3105
|
+
this._googleMaps.setEditMode(enabled);
|
|
3106
|
+
}
|
|
3107
|
+
isEditMode() {
|
|
3108
|
+
return this._googleMaps.isEditMode();
|
|
3109
|
+
}
|
|
1653
3110
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: TheSeamGoogleMapsComponent, deps: [{ token: i0.ElementRef }, { token: i1.FocusMonitor }, { token: GoogleMapsService }, { token: MapValueManagerService }, { token: TheSeamGoogleMapsApiLoader }], target: i0.ɵɵFactoryTarget.Component });
|
|
1654
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.15", type: TheSeamGoogleMapsComponent, isStandalone: false, selector: "seam-google-maps", inputs: { disabled: "disabled", value: "value", tabIndex: "tabIndex", fileDropEnabled: "fileDropEnabled", fileUploadControlEnabled: "fileUploadControlEnabled", fullscreenControlEnabled: "fullscreenControlEnabled", reCenterControlEnabled: "reCenterControlEnabled", mapTypeControlEnabled: "mapTypeControlEnabled", streetViewControlEnabled: "streetViewControlEnabled", allowDrawingHoleInPolygon: "allowDrawingHoleInPolygon", editingEnabled: "editingEnabled", fileImportHandler: "fileImportHandler", zoom: "zoom", longitude: "longitude", latitude: "latitude", padding: "padding" }, outputs: { mapReady: "mapReady" }, host: { properties: { "attr.disabled": "this._attrDisabled", "attr.tabindex": "this._attrTabIndex" } }, providers: [
|
|
3111
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.15", type: TheSeamGoogleMapsComponent, isStandalone: false, selector: "seam-google-maps", inputs: { disabled: "disabled", value: "value", tabIndex: "tabIndex", fileDropEnabled: "fileDropEnabled", fileUploadControlEnabled: "fileUploadControlEnabled", fullscreenControlEnabled: "fullscreenControlEnabled", reCenterControlEnabled: "reCenterControlEnabled", mapTypeControlEnabled: "mapTypeControlEnabled", streetViewControlEnabled: "streetViewControlEnabled", allowDrawingHoleInPolygon: "allowDrawingHoleInPolygon", editingEnabled: "editingEnabled", fileImportHandler: "fileImportHandler", zoom: "zoom", longitude: "longitude", latitude: "latitude", padding: "padding", interactionMode: "interactionMode", featureGroupProperty: "featureGroupProperty", featureLabelProperty: "featureLabelProperty", newGroupKeyFactory: "newGroupKeyFactory", selectedGroupKey: "selectedGroupKey" }, outputs: { mapReady: "mapReady", selectionChange: "selectionChange", featureHoverChange: "featureHoverChange" }, host: { properties: { "attr.disabled": "this._attrDisabled", "attr.tabindex": "this._attrTabIndex" } }, providers: [
|
|
1655
3112
|
MapValueManagerService,
|
|
1656
3113
|
GoogleMapsService,
|
|
1657
3114
|
{ provide: MAP_CONTROLS_SERVICE, useClass: GoogleMapsControlsService },
|
|
@@ -1660,7 +3117,7 @@ class TheSeamGoogleMapsComponent extends _TheSeamGoogleMapsMixinBase {
|
|
|
1660
3117
|
useExisting: forwardRef(() => TheSeamGoogleMapsComponent),
|
|
1661
3118
|
multi: true,
|
|
1662
3119
|
},
|
|
1663
|
-
], viewQueries: [{ propertyName: "featureContextMenu", first: true, predicate: ["featureContextMenu"], descendants: true, read: MenuComponent, static: true }], exportAs: ["seamGoogleMaps"], usesInheritance: true, usesOnChanges: true, ngImport: i0, template: "<ng-container *ngIf=\"_gmApiLoaded | async\">\n <google-map\n height=\"100%\"\n width=\"100%\"\n [center]=\"{ lat: latitude, lng: longitude }\"\n [zoom]=\"zoom\"\n [options]=\"_options\"\n (mapInitialized)=\"_onMapReady($any($event))\"\n >\n </google-map>\n</ng-container>\n<seam-menu #featureContextMenu>\n <button\n seamMenuItem\n *ngFor=\"let item of _contextMenuItems$ | async\"\n (click)=\"item.action(item)\"\n >\n {{ item.label }}\n </button>\n</seam-menu>\n<seam-map-file-drop *ngIf=\"fileDropEnabled\"></seam-map-file-drop>\n\n<seam-map-control\n *ngIf=\"fileUploadControlEnabled\"\n [def]=\"_fileUploadControlDef\"\n></seam-map-control>\n<seam-map-control\n *ngIf=\"reCenterControlEnabled\"\n [def]=\"_reCenterControlDef\"\n></seam-map-control>\n<seam-map-control\n *ngIf=\"editingEnabled\"\n [def]=\"_drawControlDef\"\n></seam-map-control>\n", styles: [":host{display:block;position:relative;height:400px;width:100%}:host.show-focus-border.cdk-focused{outline:0;box-shadow:0 0 0 .2rem #357ebd40}:host.show-focus-border[disabled]{opacity:.65}\n"], dependencies: [{ kind: "directive", type: i5.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i5.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: i6.MenuComponent, selector: "seam-menu", inputs: ["menuClass", "baseWidth", "animationType"], outputs: ["closed"], exportAs: ["seamMenu"] }, { kind: "component", type: i6.MenuItemComponent, selector: "[seamMenuItem]", inputs: ["disabled", "role", "icon", "iconClass", "sublevelIcon", "subLevelIconClass", "badgeText", "badgeTheme"], exportAs: ["seamMenuItem"] }, { kind: "component", type: i7.GoogleMap, selector: "google-map", inputs: ["height", "width", "mapId", "mapTypeId", "center", "zoom", "options"], outputs: ["mapInitialized", "authFailure", "boundsChanged", "centerChanged", "mapClick", "mapDblclick", "mapDrag", "mapDragend", "mapDragstart", "headingChanged", "idle", "maptypeidChanged", "mapMousemove", "mapMouseout", "mapMouseover", "projectionChanged", "mapRightclick", "tilesloaded", "tiltChanged", "zoomChanged"], exportAs: ["googleMap"] }, { kind: "component", type: TheSeamMapFileDropComponent, selector: "seam-map-file-drop" }, { kind: "component", type: TheSeamMapsControlComponent, selector: "seam-map-control", inputs: ["def"] }, { kind: "pipe", type: i5.AsyncPipe, name: "async" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
3120
|
+
], viewQueries: [{ propertyName: "featureContextMenu", first: true, predicate: ["featureContextMenu"], descendants: true, read: MenuComponent, static: true }], exportAs: ["seamGoogleMaps"], usesInheritance: true, usesOnChanges: true, ngImport: i0, template: "<ng-container *ngIf=\"_gmApiLoaded | async\">\n <google-map\n height=\"100%\"\n width=\"100%\"\n [center]=\"{ lat: latitude, lng: longitude }\"\n [zoom]=\"zoom\"\n [options]=\"_options\"\n (mapInitialized)=\"_onMapReady($any($event))\"\n >\n </google-map>\n</ng-container>\n<seam-menu #featureContextMenu>\n <button\n seamMenuItem\n *ngFor=\"let item of _contextMenuItems$ | async\"\n (click)=\"item.action(item)\"\n >\n {{ item.label }}\n </button>\n</seam-menu>\n<seam-map-file-drop *ngIf=\"fileDropEnabled\"></seam-map-file-drop>\n\n<seam-map-control\n *ngIf=\"fileUploadControlEnabled\"\n [def]=\"_fileUploadControlDef\"\n></seam-map-control>\n<seam-map-control\n *ngIf=\"reCenterControlEnabled\"\n [def]=\"_reCenterControlDef\"\n></seam-map-control>\n<seam-map-control\n *ngIf=\"editingEnabled\"\n [def]=\"_drawControlDef\"\n></seam-map-control>\n", styles: [":host{display:block;position:relative;height:400px;width:100%}:host.show-focus-border.cdk-focused{outline:0;box-shadow:0 0 0 .2rem #357ebd40}:host.show-focus-border[disabled]{opacity:.65}:host ::ng-deep .seam-map-feature-label{color:#fff;font-size:.75rem;font-weight:600;text-shadow:0 0 3px rgba(0,0,0,.9),0 0 6px rgba(0,0,0,.7);-webkit-user-select:none;user-select:none}\n"], dependencies: [{ kind: "directive", type: i5.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i5.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: i6.MenuComponent, selector: "seam-menu", inputs: ["menuClass", "baseWidth", "animationType"], outputs: ["closed"], exportAs: ["seamMenu"] }, { kind: "component", type: i6.MenuItemComponent, selector: "[seamMenuItem]", inputs: ["disabled", "role", "icon", "iconClass", "sublevelIcon", "subLevelIconClass", "badgeText", "badgeTheme"], exportAs: ["seamMenuItem"] }, { kind: "component", type: i7.GoogleMap, selector: "google-map", inputs: ["height", "width", "mapId", "mapTypeId", "center", "zoom", "options"], outputs: ["mapInitialized", "authFailure", "boundsChanged", "centerChanged", "mapClick", "mapDblclick", "mapDrag", "mapDragend", "mapDragstart", "headingChanged", "idle", "maptypeidChanged", "mapMousemove", "mapMouseout", "mapMouseover", "projectionChanged", "mapRightclick", "tilesloaded", "tiltChanged", "zoomChanged"], exportAs: ["googleMap"] }, { kind: "component", type: TheSeamMapFileDropComponent, selector: "seam-map-file-drop" }, { kind: "component", type: TheSeamMapsControlComponent, selector: "seam-map-control", inputs: ["def"] }, { kind: "pipe", type: i5.AsyncPipe, name: "async" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1664
3121
|
}
|
|
1665
3122
|
__decorate([
|
|
1666
3123
|
InputBoolean()
|
|
@@ -1706,7 +3163,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImpo
|
|
|
1706
3163
|
useExisting: forwardRef(() => TheSeamGoogleMapsComponent),
|
|
1707
3164
|
multi: true,
|
|
1708
3165
|
},
|
|
1709
|
-
], changeDetection: ChangeDetectionStrategy.OnPush, exportAs: 'seamGoogleMaps', standalone: false, template: "<ng-container *ngIf=\"_gmApiLoaded | async\">\n <google-map\n height=\"100%\"\n width=\"100%\"\n [center]=\"{ lat: latitude, lng: longitude }\"\n [zoom]=\"zoom\"\n [options]=\"_options\"\n (mapInitialized)=\"_onMapReady($any($event))\"\n >\n </google-map>\n</ng-container>\n<seam-menu #featureContextMenu>\n <button\n seamMenuItem\n *ngFor=\"let item of _contextMenuItems$ | async\"\n (click)=\"item.action(item)\"\n >\n {{ item.label }}\n </button>\n</seam-menu>\n<seam-map-file-drop *ngIf=\"fileDropEnabled\"></seam-map-file-drop>\n\n<seam-map-control\n *ngIf=\"fileUploadControlEnabled\"\n [def]=\"_fileUploadControlDef\"\n></seam-map-control>\n<seam-map-control\n *ngIf=\"reCenterControlEnabled\"\n [def]=\"_reCenterControlDef\"\n></seam-map-control>\n<seam-map-control\n *ngIf=\"editingEnabled\"\n [def]=\"_drawControlDef\"\n></seam-map-control>\n", styles: [":host{display:block;position:relative;height:400px;width:100%}:host.show-focus-border.cdk-focused{outline:0;box-shadow:0 0 0 .2rem #357ebd40}:host.show-focus-border[disabled]{opacity:.65}\n"] }]
|
|
3166
|
+
], changeDetection: ChangeDetectionStrategy.OnPush, exportAs: 'seamGoogleMaps', standalone: false, template: "<ng-container *ngIf=\"_gmApiLoaded | async\">\n <google-map\n height=\"100%\"\n width=\"100%\"\n [center]=\"{ lat: latitude, lng: longitude }\"\n [zoom]=\"zoom\"\n [options]=\"_options\"\n (mapInitialized)=\"_onMapReady($any($event))\"\n >\n </google-map>\n</ng-container>\n<seam-menu #featureContextMenu>\n <button\n seamMenuItem\n *ngFor=\"let item of _contextMenuItems$ | async\"\n (click)=\"item.action(item)\"\n >\n {{ item.label }}\n </button>\n</seam-menu>\n<seam-map-file-drop *ngIf=\"fileDropEnabled\"></seam-map-file-drop>\n\n<seam-map-control\n *ngIf=\"fileUploadControlEnabled\"\n [def]=\"_fileUploadControlDef\"\n></seam-map-control>\n<seam-map-control\n *ngIf=\"reCenterControlEnabled\"\n [def]=\"_reCenterControlDef\"\n></seam-map-control>\n<seam-map-control\n *ngIf=\"editingEnabled\"\n [def]=\"_drawControlDef\"\n></seam-map-control>\n", styles: [":host{display:block;position:relative;height:400px;width:100%}:host.show-focus-border.cdk-focused{outline:0;box-shadow:0 0 0 .2rem #357ebd40}:host.show-focus-border[disabled]{opacity:.65}:host ::ng-deep .seam-map-feature-label{color:#fff;font-size:.75rem;font-weight:600;text-shadow:0 0 3px rgba(0,0,0,.9),0 0 6px rgba(0,0,0,.7);-webkit-user-select:none;user-select:none}\n"] }]
|
|
1710
3167
|
}], ctorParameters: () => [{ type: i0.ElementRef }, { type: i1.FocusMonitor }, { type: GoogleMapsService }, { type: MapValueManagerService }, { type: TheSeamGoogleMapsApiLoader }], propDecorators: { value: [{
|
|
1711
3168
|
type: Input
|
|
1712
3169
|
}], tabIndex: [{
|
|
@@ -1743,8 +3200,22 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImpo
|
|
|
1743
3200
|
type: Input
|
|
1744
3201
|
}], padding: [{
|
|
1745
3202
|
type: Input
|
|
3203
|
+
}], interactionMode: [{
|
|
3204
|
+
type: Input
|
|
3205
|
+
}], featureGroupProperty: [{
|
|
3206
|
+
type: Input
|
|
3207
|
+
}], featureLabelProperty: [{
|
|
3208
|
+
type: Input
|
|
3209
|
+
}], newGroupKeyFactory: [{
|
|
3210
|
+
type: Input
|
|
3211
|
+
}], selectedGroupKey: [{
|
|
3212
|
+
type: Input
|
|
1746
3213
|
}], mapReady: [{
|
|
1747
3214
|
type: Output
|
|
3215
|
+
}], selectionChange: [{
|
|
3216
|
+
type: Output
|
|
3217
|
+
}], featureHoverChange: [{
|
|
3218
|
+
type: Output
|
|
1748
3219
|
}], featureContextMenu: [{
|
|
1749
3220
|
type: ViewChild,
|
|
1750
3221
|
args: ['featureContextMenu', { static: true, read: MenuComponent }]
|
|
@@ -2325,5 +3796,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImpo
|
|
|
2325
3796
|
* Generated bundle index. Do not edit.
|
|
2326
3797
|
*/
|
|
2327
3798
|
|
|
2328
|
-
export { AppFeaturePropertyName, GoogleMapsContextMenu, GoogleMapsControlsService, GoogleMapsService, MAP_CONTROLS_SERVICE, MAP_CONTROL_DATA, MapControlRef, MapValueManagerService, MapValueSource, SEAM_GOOGLE_PLACES_AUTOCOMPLETE_DEFAULT_OPTIONS, THESEAM_LAZY_MAPS_API_CONFIG, TheSeamGoogleMapsApiLoader, TheSeamGoogleMapsComponent, TheSeamGoogleMapsModule, TheSeamGoogleMapsPlacesAutoCompleteComponent, TheSeamGoogleMapsPlacesAutocompleteDirective, TheSeamGoogleMapsRecenterButtonControlComponent, TheSeamGoogleMapsUploadButtonControlComponent, TheSeamLazyMapsApiLoader, TheSeamMapFileDropComponent, TheSeamMapsControlComponent, TheSeamNoopGoogleMapsAPILoader, createFeatureChangeObservable, dataPolygonFromGeoJson, geoJsonPolygonFromDataFeature, getBoundsWithAllFeatures, getFeatureBounds, getFeatureCenter, getFeaturesCount, getHoveredStyleOptionsDefinedByFeature, getSelectedStyleOptionsDefinedByFeature, getStyleOptionsDefinedByFeature, isAppFeatureProperty, isFeatureSelected, polygonCoordinates, polygonHasValidPathsLengths, removeAllFeatures, setFeatureSelected, stripAppFeaturePropertiesFromJson };
|
|
3799
|
+
export { AppFeaturePropertyName, FEATURE_STYLE_OPTIONS_DEFAULT, FEATURE_STYLE_OPTIONS_SELECTED, FEATURE_STYLE_OVERRIDE_OPTIONS_HOVERED, GoogleMapsContextMenu, GoogleMapsControlsService, GoogleMapsService, MAP_CONTROLS_SERVICE, MAP_CONTROL_DATA, MapControlRef, MapValueManagerService, MapValueSource, SEAM_GOOGLE_PLACES_AUTOCOMPLETE_DEFAULT_OPTIONS, SUPPORTED_PROPERTY_STYLE_OPTIONS, THESEAM_LAZY_MAPS_API_CONFIG, TheSeamGoogleMapsApiLoader, TheSeamGoogleMapsComponent, TheSeamGoogleMapsModule, TheSeamGoogleMapsPlacesAutoCompleteComponent, TheSeamGoogleMapsPlacesAutocompleteDirective, TheSeamGoogleMapsRecenterButtonControlComponent, TheSeamGoogleMapsUploadButtonControlComponent, TheSeamLazyMapsApiLoader, TheSeamMapFileDropComponent, TheSeamMapsControlComponent, TheSeamNoopGoogleMapsAPILoader, applyHoleToFeature, computeFeatureHoverStyle, computeFeatureStyle, createFeatureChangeObservable, dataMultiPolygonFromGeoJson, dataPolygonFromGeoJson, geoJsonFeatureFromDataFeature, geoJsonPolygonFromDataFeature, getBoundsWithAllFeatures, getFeatureBounds, getFeatureCenter, getFeaturesCount, getHoveredStyleOptionsDefinedByFeature, getSelectedStyleOptionsDefinedByFeature, getStyleOptionsDefinedByFeature, isAppFeatureProperty, isFeatureSelected, mergeStyleOptions, polygonCoordinates, polygonHasValidPathsLengths, polygonsFromDataFeature, removeAllFeatures, setFeatureSelected, stripAppFeaturePropertiesFromJson };
|
|
2329
3800
|
//# sourceMappingURL=theseam-ui-common-google-maps.mjs.map
|