@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.
@@ -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, polygonHasMinDistinctVertices, addHoleToPolygon, polygonContains, readGeoFile } from '@theseam/ui-common/utils';
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
- if (this._terraDraw?.enabled) {
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.googleMap.data.forEach((f) => {
501
- if (isFeatureSelected(f)) {
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
- /** Whether polygon drawing mode is currently active. */
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._terraDraw?.getMode() === 'polyline';
1295
+ return this._drawingSubject.value;
534
1296
  }
535
1297
  /** Enter polygon drawing mode. */
536
1298
  startDrawing() {
537
- if (!this._terraDraw || !this._terraDrawReady || !this.isEditingEnabled()) {
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
- // Clear any selection when entering drawing mode. Otherwise a selected
541
- // feature and the shape being drawn both appear selected, reading as one
542
- // shape even though they are unrelated.
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.googleMap.data.forEach((f) => {
545
- if (isFeatureSelected(f)) {
546
- setFeatureSelected(f, false);
547
- }
548
- });
549
- this._terraDraw.setMode('polyline');
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
- this._terraDraw = draw;
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
- _initFeatureStyling() {
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
- // Disable any selection when clicking the map.
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
- // TODO: There may be a better way to do this that would be more accurate or
703
- // additional events that should be listened to, such as the disabling
704
- // selection when the map looses focus.
705
- this.googleMap.addListener('click', (even) => {
706
- this._assertInitialized();
707
- this.googleMap.data.forEach((f) => setFeatureSelected(f, false));
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
- // Determine what the style of the features are.
710
- this.googleMap.data.setStyle((feature) => {
711
- let opts = FEATURE_STYLE_OPTIONS_DEFAULT(this.isEditingEnabled());
712
- const options = getStyleOptionsDefinedByFeature(feature);
713
- this._mergeStyleOptions(opts, options ?? {});
714
- if (isFeatureSelected(feature)) {
715
- const hoverOptions = getHoveredStyleOptionsDefinedByFeature(feature);
716
- opts = FEATURE_STYLE_OPTIONS_SELECTED(this.isEditingEnabled());
717
- this._mergeStyleOptions(opts, hoverOptions ?? {});
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
- return opts;
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
- // Select a feature when clicked.
1980
+ this.googleMap.data.setStyle(this._styleFn);
722
1981
  this.googleMap.data.addListener('click', (event) => {
723
- this._assertInitialized();
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
- setFeatureSelected(event.feature, true);
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
- // Remove any hover styles when mouse moves away.
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
- const overrideOpts = FEATURE_STYLE_OVERRIDE_OPTIONS_HOVERED(this.isEditingEnabled());
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) => this._mapValueManager.setValue(geoJson, MapValueSource.FeatureChange)))), takeUntil(this._ngUnsubscribe))
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 (!isFeatureSelected(event.feature)) {
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 exteriorFeature = this._allowDrawingHoleInPolygon
856
- ? this._getPossibleExteriorFeature(drawn)
857
- : undefined;
858
- if (exteriorFeature) {
859
- const exteriorPolygon = geoJsonPolygonFromDataFeature(exteriorFeature);
860
- if (exteriorPolygon) {
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
- setFeatureSelected(newFeature, true);
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
- _active = toSignal(this._googleMaps.drawing$, {
2271
+ _mode = toSignal(this._googleMaps.interactionMode$, {
2272
+ initialValue: 'legacy',
2273
+ });
2274
+ _drawing = toSignal(this._googleMaps.drawing$, {
1001
2275
  initialValue: false,
1002
2276
  });
1003
- label = this._data?.label ?? 'Draw Field';
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
- this._contextMenuItems$ = this._googleMaps.editingEnabled$.pipe(map((enabled) => {
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._onClickDeleteFeature(),
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._googleMaps.deleteSelection();
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._googleMaps.stopDrawing();
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