@theseam/ui-common 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,18 +1,19 @@
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, HostListener, Input, Optional, Inject, ChangeDetectionStrategy, Component, inject, ChangeDetectorRef, EventEmitter, forwardRef, ViewChild, Output, HostBinding, Directive, NgModule, LOCALE_ID } 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';
5
5
  import { NG_VALUE_ACCESSOR } from '@angular/forms';
6
6
  import { Observable, Subject, fromEvent, BehaviorSubject, from, of, interval, defer, throwError } from 'rxjs';
7
7
  import { takeUntil, switchMap, tap, map, catchError, startWith, filter, take, mapTo } from 'rxjs/operators';
8
- import { faFileImport, faCrosshairs, faSearchLocation } from '@fortawesome/free-solid-svg-icons';
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
- import { notNullOrUndefined, isNullOrUndefined, readGeoFile } from '@theseam/ui-common/utils';
12
+ import { TerraDraw, TerraDrawPolyLineMode } from 'terra-draw';
13
+ import { TerraDrawGoogleMapsAdapter } from 'terra-draw-google-maps-adapter';
14
+ import { notNullOrUndefined, closePolygons, polygonHasMinDistinctVertices, addHoleToPolygon, polygonContains, readGeoFile } from '@theseam/ui-common/utils';
13
15
  import { ESCAPE } from '@angular/cdk/keycodes';
14
- import booleanContains from '@turf/boolean-contains';
15
- import { polygon, multiPolygon } from '@turf/helpers';
16
+ import { toSignal } from '@angular/core/rxjs-interop';
16
17
  import * as i2 from '@theseam/ui-common/icon';
17
18
  import { TheSeamIconModule } from '@theseam/ui-common/icon';
18
19
  import * as i1 from '@angular/cdk/a11y';
@@ -71,64 +72,6 @@ function stripAppFeaturePropertiesFromJson(json) {
71
72
  }
72
73
  }
73
74
  }
74
- /**
75
- * Searches for a Feature in Data that contains the provided Feature and can use
76
- * it as a cutout area.
77
- */
78
- function getPossibleExteriorFeature(data, feature) {
79
- let exteriorPolygonFeature;
80
- data.forEach((f) => {
81
- if (f === feature) {
82
- return;
83
- }
84
- const geometry = f.getGeometry();
85
- if (geometry === null) {
86
- throw Error(`Geometry not found.`);
87
- }
88
- if (geometry.getType() === 'Polygon' && featureContains(f, feature)) {
89
- exteriorPolygonFeature = f;
90
- }
91
- });
92
- return exteriorPolygonFeature;
93
- }
94
- function addInnerFeatureCutoutToExteriorFeature(exteriorFeature, innerFeature) {
95
- const exteriorGeometry = exteriorFeature.getGeometry();
96
- if (exteriorGeometry === null) {
97
- throw Error(`Geometry not found.`);
98
- }
99
- const innerGeometry = innerFeature.getGeometry();
100
- if (innerGeometry === null) {
101
- throw Error(`Geometry not found.`);
102
- }
103
- // NOTE: Other geometries may support cutouts, but our map shapes editor only
104
- // supports polygons currently, so we will need to handle other geometry types
105
- // here if we start allowing users to draw shapes other than polygon.
106
- if (exteriorGeometry.getType() !== 'Polygon' ||
107
- innerGeometry.getType() !== 'Polygon') {
108
- throw Error(`Inner cutout is only supported by Polygon gemoetry.`);
109
- }
110
- const featurePolygon = innerGeometry;
111
- const exteriorPolygon = exteriorGeometry;
112
- exteriorFeature.setGeometry(new google.maps.Data.Polygon([
113
- ...exteriorPolygon.getArray(),
114
- featurePolygon.getAt(0).getArray().reverse(),
115
- ]));
116
- }
117
- /**
118
- * Google maps paths don't always start and stop at the exact same position, so
119
- * this will fix that for turfjs.
120
- */
121
- function fixPathDifferentStartingAndEndingPoint(coordinates) {
122
- if (coordinates.length <= 1) {
123
- return;
124
- }
125
- const start = coordinates[0];
126
- const end = coordinates[coordinates.length - 1];
127
- if (start[0] === end[0] && start[1] === end[1]) {
128
- return;
129
- }
130
- coordinates.push(coordinates[0]);
131
- }
132
75
  function polygonHasValidPathsLengths(polygon, minPointsInValidPath = 3) {
133
76
  const paths = polygon.getPaths().getArray();
134
77
  for (const path of paths) {
@@ -139,47 +82,50 @@ function polygonHasValidPathsLengths(polygon, minPointsInValidPath = 3) {
139
82
  return true;
140
83
  }
141
84
  function polygonCoordinates(polygon) {
142
- return polygon.getArray().map((linRing) => {
143
- const coords = linRing.getArray().map((x) => [x.lng(), x.lat()]);
144
- fixPathDifferentStartingAndEndingPoint(coords);
145
- return coords;
146
- });
147
- }
148
- function multiPolygonCoordinates(multiPolygon) {
149
- return multiPolygon.getArray().map((x) => polygonCoordinates(x));
85
+ const polygonGeoJson = {
86
+ type: 'Polygon',
87
+ coordinates: polygon
88
+ .getArray()
89
+ .map((linRing) => linRing.getArray().map((x) => [x.lng(), x.lat()])),
90
+ };
91
+ closePolygons(polygonGeoJson);
92
+ return polygonGeoJson.coordinates;
150
93
  }
151
- function toTurfJsPolygon(polygon$1) {
152
- return polygon(polygonCoordinates(polygon$1));
94
+ /** Build a google.maps.Data.Polygon from a GeoJSON Polygon (lng/lat order). */
95
+ function dataPolygonFromGeoJson(polygon) {
96
+ const rings = polygon.coordinates.map((ring) =>
97
+ // GeoJSON rings are explicitly closed (first coordinate repeated at the
98
+ // end), but google.maps.Data.Polygon paths are implicitly closed and must
99
+ // NOT include the repeated point — otherwise it becomes a duplicate vertex
100
+ // coincident with the first (two points that edit independently, and a
101
+ // double closing point when serialized back to GeoJSON).
102
+ openRing(ring).map(([lng, lat]) => ({ lat, lng })));
103
+ return new google.maps.Data.Polygon(rings);
153
104
  }
154
- function toTurfJsMultiPolygon(multiPolygon$1) {
155
- return multiPolygon(multiPolygonCoordinates(multiPolygon$1));
105
+ /** Drop a ring's explicit closing point (a trailing copy of the first). */
106
+ function openRing(ring) {
107
+ if (ring.length < 2) {
108
+ return ring;
109
+ }
110
+ const first = ring[0];
111
+ const last = ring[ring.length - 1];
112
+ return first[0] === last[0] && first[1] === last[1] ? ring.slice(0, -1) : ring;
156
113
  }
157
- function toTurfJsFeature(googleFeature) {
158
- const geometry = googleFeature.getGeometry();
159
- if (geometry === null) {
160
- throw Error(`Geometry not found.`);
161
- }
162
- if (geometry.getType() === 'Polygon') {
163
- return toTurfJsPolygon(geometry);
164
- }
165
- else if (geometry.getType() === 'MultiPolygon') {
166
- return toTurfJsMultiPolygon(geometry);
114
+ /**
115
+ * Read an existing feature's Polygon geometry as a closed GeoJSON Polygon.
116
+ * Returns undefined for non-Polygon geometries. Does not mutate the feature.
117
+ */
118
+ function geoJsonPolygonFromDataFeature(feature) {
119
+ const geometry = feature.getGeometry();
120
+ if (geometry === null || geometry.getType() !== 'Polygon') {
121
+ return undefined;
167
122
  }
168
- throw Error(`Unexpected geometry.`);
169
- }
170
- function featureContains(featureA, featureB) {
171
- const polygonA = toTurfJsFeature(featureA);
172
- const polygonB = toTurfJsFeature(featureB);
173
- return booleanContains(polygonA, polygonB);
174
- }
175
- function createDataFeatureFromPolygon(polygon) {
176
- const arr = polygon
177
- .getPaths()
178
- .getArray()
179
- .map((x) => x.getArray());
180
- return new google.maps.Data.Feature({
181
- geometry: new google.maps.Data.Polygon(arr),
182
- });
123
+ const polygon = {
124
+ type: 'Polygon',
125
+ coordinates: polygonCoordinates(geometry),
126
+ };
127
+ closePolygons(polygon);
128
+ return polygon;
183
129
  }
184
130
  function getBoundsWithAllFeatures(data) {
185
131
  const bounds = new google.maps.LatLngBounds();
@@ -440,19 +386,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImpo
440
386
  type: Injectable
441
387
  }], ctorParameters: () => [] });
442
388
 
443
- const DEFAULT_POLYGON_OPTIONS = (editingEnabled) => ({
444
- clickable: editingEnabled,
445
- draggable: editingEnabled,
446
- editable: editingEnabled,
447
- });
448
- const DEFAULT_DRAWING_MANAGER_OPTIONS = (editingEnabled) => ({
449
- drawingControl: editingEnabled,
450
- drawingControlOptions: {
451
- drawingModes: [google.maps.drawing.OverlayType.POLYGON],
452
- },
453
- polygonOptions: DEFAULT_POLYGON_OPTIONS(editingEnabled),
454
- drawingMode: null,
455
- });
456
389
  const FEATURE_STYLE_OPTIONS_DEFAULT = (editingEnabled) => ({
457
390
  clickable: true,
458
391
  // clickable: editingEnabled,
@@ -501,7 +434,10 @@ class GoogleMapsService {
501
434
  _ngUnsubscribe = new Subject();
502
435
  _mapReadySubject = new BehaviorSubject(false);
503
436
  _editingEnabledSubject = new BehaviorSubject(true);
504
- _drawingManager;
437
+ _terraDraw;
438
+ _terraDrawReady = false;
439
+ _drawingSubject = new BehaviorSubject(false);
440
+ drawing$ = this._drawingSubject.asObservable();
505
441
  _featureContextMenu = null;
506
442
  _activeContextMenu = null;
507
443
  _baseLatLng;
@@ -523,13 +459,23 @@ class GoogleMapsService {
523
459
  this.mapReady$ = this._mapReadySubject.asObservable();
524
460
  }
525
461
  ngOnDestroy() {
462
+ if (this._terraDraw?.enabled) {
463
+ this._terraDraw.stop();
464
+ }
465
+ this._terraDraw = undefined;
466
+ this._drawingSubject.complete();
526
467
  this._ngUnsubscribe.next();
527
468
  this._ngUnsubscribe.complete();
528
469
  }
529
470
  setMap(map) {
530
471
  this.googleMap = map;
531
472
  this._mapReadySubject.next(true);
532
- this._initDrawingManager();
473
+ // Terra Draw's Google Maps adapter binds its event listeners to the map's
474
+ // rendered '.gm-style' DOM subtree (located via querySelector). That subtree
475
+ // does not exist yet when 'mapInitialized' fires, so initializing Terra Draw
476
+ // synchronously here makes the adapter register listeners on a null element
477
+ // and throw. Defer init until the map has rendered (first 'idle').
478
+ google.maps.event.addListenerOnce(map, 'idle', () => this._initTerraDraw());
533
479
  this._initFeatureStyling();
534
480
  this._initFeatureChangeListeners();
535
481
  }
@@ -551,20 +497,12 @@ class GoogleMapsService {
551
497
  this.googleMap.data.revertStyle();
552
498
  if (!enabled) {
553
499
  this.stopDrawing();
554
- const options = DEFAULT_DRAWING_MANAGER_OPTIONS(this.isEditingEnabled());
555
- this._drawingManager?.setOptions(options);
556
- this._drawingManager?.setMap(null);
557
500
  this.googleMap.data.forEach((f) => {
558
501
  if (isFeatureSelected(f)) {
559
502
  setFeatureSelected(f, false);
560
503
  }
561
504
  });
562
505
  }
563
- else {
564
- const options = DEFAULT_DRAWING_MANAGER_OPTIONS(this.isEditingEnabled());
565
- this._drawingManager?.setOptions(options);
566
- this._drawingManager?.setMap(this.googleMap);
567
- }
568
506
  }
569
507
  }
570
508
  isEditingEnabled() {
@@ -590,58 +528,138 @@ class GoogleMapsService {
590
528
  }
591
529
  });
592
530
  }
531
+ /** Whether polygon drawing mode is currently active. */
532
+ isDrawing() {
533
+ return this._terraDraw?.getMode() === 'polyline';
534
+ }
535
+ /** Enter polygon drawing mode. */
536
+ startDrawing() {
537
+ if (!this._terraDraw || !this._terraDrawReady || !this.isEditingEnabled()) {
538
+ return;
539
+ }
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.
543
+ this._assertInitialized();
544
+ this.googleMap.data.forEach((f) => {
545
+ if (isFeatureSelected(f)) {
546
+ setFeatureSelected(f, false);
547
+ }
548
+ });
549
+ this._terraDraw.setMode('polyline');
550
+ this._drawingSubject.next(true);
551
+ }
593
552
  /**
594
- * Stops the current drawing.
553
+ * Cancel any in-progress drawing and leave drawing mode. Switching to the
554
+ * `static` mode clears an unfinished line.
595
555
  */
596
556
  stopDrawing() {
597
- this._ngZone.runOutsideAngular(() => {
598
- if (isNullOrUndefined(this._drawingManager) ||
599
- this._drawingManager.getDrawingMode() === null) {
600
- return;
601
- }
602
- // Listening for the completion event of the overlay currently being drawn
603
- // and removing it. I haven't found a way to cancel the current drawing,
604
- // without this hack that assumes our listeners will not run into a race
605
- // condition.
606
- const listener = google.maps.event.addListener(this._drawingManager, 'overlaycomplete', (event) => {
607
- event.overlay?.setMap(null);
608
- listener.remove();
609
- });
610
- // To fake canceling the current drawing, without disabling the drawing
611
- // mode, the drawing mode is being unset then immediately set back. When
612
- // the mode is unset the 'overlaycomplete' event will fire, which will
613
- // give a reference to the current overlay to remove, then it is set back
614
- // to the mode the user was using. To the user is should just seem like
615
- // the current drawing was canceled and they can start drawing again.
616
- const mode = this._drawingManager.getDrawingMode();
617
- this._drawingManager.setDrawingMode(null);
618
- this._drawingManager.setDrawingMode(mode);
619
- // 'overlaycomplete' should fire immediately, unless an overlay hadn't
620
- // started drawing. This timeout will make sure the listener gets removed.
621
- setTimeout(() => {
622
- listener.remove();
623
- });
624
- });
557
+ if (!this._terraDraw || !this._terraDrawReady) {
558
+ return;
559
+ }
560
+ this._terraDraw.setMode('static');
561
+ this._drawingSubject.next(false);
625
562
  }
626
- _initDrawingManager() {
627
- if (notNullOrUndefined(this._drawingManager)) {
628
- throw Error(`DrawingManager is already initialized.`);
563
+ _initTerraDraw() {
564
+ if (notNullOrUndefined(this._terraDraw)) {
565
+ throw Error(`Terra Draw is already initialized.`);
629
566
  }
630
567
  this._assertInitialized();
631
- const options = DEFAULT_DRAWING_MANAGER_OPTIONS(this.isEditingEnabled());
632
- const drawingManager = new google.maps.drawing.DrawingManager(options);
633
- drawingManager.setMap(this.googleMap);
634
- this._drawingManager = drawingManager;
635
- this._drawingManager.addListener('drawingmode_changed', (event) => {
636
- if (this._drawingManager?.getDrawingMode() !== null) {
637
- this._assertInitialized();
638
- this.googleMap.data.forEach((f) => {
639
- if (isFeatureSelected(f)) {
640
- setFeatureSelected(f, false);
641
- }
642
- });
568
+ // The Google Maps adapter creates an OverlayView on the map; ensure the map
569
+ // element has an id (harmless if one is already present).
570
+ const div = this.googleMap.getDiv();
571
+ if (!div.id) {
572
+ div.id = `seam-google-map-${Math.floor(performance.now())}`;
573
+ }
574
+ const pointerDistance = 10;
575
+ // KNOWN ISSUE (upstream, unresolved): after repeated draws, Terra Draw can
576
+ // intermittently lose pointer control of the Google Maps adapter, so input
577
+ // pans the map instead of drawing until the page is reloaded. This is a race
578
+ // between Terra Draw and the Google Maps adapter, tracked at
579
+ // https://github.com/JamesLMilner/terra-draw/issues/710 (open as of
580
+ // terra-draw 1.32.0 / adapter 1.6.1). It is not fixable in this wrapper;
581
+ // revisit when the upstream issue is resolved.
582
+ const draw = new TerraDraw({
583
+ adapter: new TerraDrawGoogleMapsAdapter({
584
+ lib: google.maps,
585
+ map: this.googleMap,
586
+ // Render Terra Draw's in-progress/finished geometry on its OWN Data
587
+ // layer instead of the map's shared `map.data`. Without this, Terra
588
+ // Draw's features land in the same layer this service manages, so the
589
+ // exterior-feature search would match a just-drawn polygon against its
590
+ // own rendered copy (cutting a hole equal to itself) and getGeoJson
591
+ // would serialize Terra Draw's transient features.
592
+ isolatedData: true,
593
+ }),
594
+ modes: [
595
+ new TerraDrawPolyLineMode({
596
+ // Tighten how close a click must be to the first point to close the
597
+ // polygon (Terra Draw's default ~40px felt too far / closed too
598
+ // easily).
599
+ pointerDistance,
600
+ // Snap the moving vertex onto the line's OWN first point when the
601
+ // cursor is within a few pixels of it, so it's clear the ends are
602
+ // about to meet just before the click that closes the polygon.
603
+ // (Terra Draw's built-in `toCoordinate` only snaps to OTHER features'
604
+ // points — excluding the line being drawn — so it does nothing here.)
605
+ snapping: {
606
+ toCustom: (event, ctx) => {
607
+ const geometry = ctx.getCurrentGeometrySnapshot();
608
+ if (!geometry) {
609
+ return undefined;
610
+ }
611
+ const ring = geometry.type === 'LineString'
612
+ ? geometry.coordinates
613
+ : geometry.coordinates[0];
614
+ // Only offer the closing snap once enough points exist to form a
615
+ // polygon (placed points plus the live cursor point).
616
+ if (!ring || ring.length < 4) {
617
+ return undefined;
618
+ }
619
+ const first = ring[0];
620
+ const firstPixel = ctx.project(first[0], first[1]);
621
+ const distance = Math.hypot(firstPixel.x - event.containerX, firstPixel.y - event.containerY);
622
+ const SNAP_DISTANCE_PX = pointerDistance;
623
+ return distance <= SNAP_DISTANCE_PX ? first : undefined;
624
+ },
625
+ },
626
+ // Draw as an open line that closes into a polygon when the start point
627
+ // is clicked (the old Drawing manager's feel): while drawing you see a
628
+ // line, and the shape only "fills in" once committed to the map's Data
629
+ // layer, which is the clear "done" signal. Black line, small white
630
+ // closing/snapping dots (like the edit anchors), no fill on Terra
631
+ // Draw's transient polygon. Terra Draw requires hex colors.
632
+ styles: {
633
+ lineStringColor: '#000000',
634
+ lineStringWidth: 2,
635
+ polygonFillOpacity: 0,
636
+ polygonOutlineColor: '#000000',
637
+ polygonOutlineWidth: 2,
638
+ closingPointColor: '#ffffff',
639
+ closingPointWidth: 4,
640
+ closingPointOutlineColor: '#000000',
641
+ closingPointOutlineWidth: 1,
642
+ snappingPointColor: '#ffffff',
643
+ snappingPointWidth: 4,
644
+ snappingPointOutlineColor: '#000000',
645
+ snappingPointOutlineWidth: 1,
646
+ },
647
+ }),
648
+ ],
649
+ });
650
+ draw.on('ready', () => {
651
+ this._terraDrawReady = true;
652
+ // Start in the resting (non-drawing) mode.
653
+ draw.setMode('static');
654
+ });
655
+ draw.on('finish', (id, context) => {
656
+ if (context.action !== 'draw') {
657
+ return;
643
658
  }
659
+ this._ngZone.run(() => this._onDrawFinished(id));
644
660
  });
661
+ draw.start();
662
+ this._terraDraw = draw;
645
663
  }
646
664
  addControl(element, position) {
647
665
  this._assertInitialized();
@@ -703,6 +721,12 @@ class GoogleMapsService {
703
721
  // Select a feature when clicked.
704
722
  this.googleMap.data.addListener('click', (event) => {
705
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).
727
+ if (this.isDrawing()) {
728
+ return;
729
+ }
706
730
  setFeatureSelected(event.feature, true);
707
731
  this.googleMap.data.forEach((f) => {
708
732
  if (f !== event.feature && isFeatureSelected(f)) {
@@ -752,54 +776,6 @@ class GoogleMapsService {
752
776
  }
753
777
  this._openContextMenuForFeature(event.feature, event.latLng ?? undefined);
754
778
  });
755
- if (notNullOrUndefined(this._drawingManager)) {
756
- google.maps.event.addListener(this._drawingManager, 'polygoncomplete', (polygon) => {
757
- // The DrawingManager doesn't seem to have a way to access the overlays,
758
- // so if the map is not set then it shouldn'y be considered a successful
759
- // completion. I am canceling the active drawing by disabling drawing
760
- // mode and setting the map null in the 'overlaycomplete' event, which
761
- // fires before the 'polygoncomplete' event.
762
- if (isNullOrUndefined(polygon.getMap())) {
763
- return;
764
- }
765
- this._assertInitialized();
766
- // TODO: See if there is a way to prevent the polygon from completing
767
- // without enough points. This is very low priority, because starting
768
- // over after adding a single point isn't a major inconvenience.
769
- if (!polygonHasValidPathsLengths(polygon)) {
770
- // Remove the drawn polygon.
771
- polygon.setMap(null);
772
- // Stop drawing.
773
- this._drawingManager?.setDrawingMode(null);
774
- return;
775
- }
776
- // Create a map feature of the drawn polygon.
777
- const feature = createDataFeatureFromPolygon(polygon);
778
- // Remove the drawn polygon.
779
- polygon.setMap(null);
780
- // Stop drawing.
781
- this._drawingManager?.setDrawingMode(null);
782
- // Check if the feature should be used as a cutout to an existing
783
- // feature or added as it's own feature.
784
- const exteriorPolygonFeature = this._allowDrawingHoleInPolygon
785
- ? getPossibleExteriorFeature(this.googleMap.data, feature)
786
- : undefined;
787
- if (exteriorPolygonFeature) {
788
- addInnerFeatureCutoutToExteriorFeature(exteriorPolygonFeature, feature);
789
- setFeatureSelected(exteriorPolygonFeature, true);
790
- }
791
- else {
792
- this.googleMap.data.add(feature);
793
- setFeatureSelected(feature, true);
794
- }
795
- });
796
- }
797
- }
798
- isDrawing() {
799
- if (isNullOrUndefined(this._drawingManager)) {
800
- return true;
801
- }
802
- return this._drawingManager.getDrawingMode() !== null;
803
779
  }
804
780
  // TODO: Refactor out of the service meant to just wrap the google maps api.
805
781
  hasSelectedFeature() {
@@ -867,6 +843,74 @@ class GoogleMapsService {
867
843
  'Please wait for the API to load before trying to interact with it.');
868
844
  }
869
845
  }
846
+ _onDrawFinished(id) {
847
+ const feature = this._terraDraw?.getSnapshotFeature(id);
848
+ this._terraDraw?.removeFeatures([id]);
849
+ this.stopDrawing();
850
+ const drawn = feature ? this._toDrawnPolygon(feature.geometry) : undefined;
851
+ if (!drawn || !polygonHasMinDistinctVertices(drawn, 3)) {
852
+ return;
853
+ }
854
+ 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
+ }
868
+ }
869
+ const newFeature = new google.maps.Data.Feature({
870
+ geometry: dataPolygonFromGeoJson(drawn),
871
+ });
872
+ this.googleMap.data.add(newFeature);
873
+ setFeatureSelected(newFeature, true);
874
+ }
875
+ /**
876
+ * Normalize a finished Terra Draw geometry into a Polygon. A Polygon (the
877
+ * user closed the line on its start point) passes through; an open LineString
878
+ * (the user finished without closing) is closed into a polygon so nothing
879
+ * they drew is discarded. Any other geometry type yields undefined.
880
+ */
881
+ _toDrawnPolygon(geometry) {
882
+ if (geometry.type === 'Polygon') {
883
+ return geometry;
884
+ }
885
+ if (geometry.type === 'LineString') {
886
+ const polygon = {
887
+ type: 'Polygon',
888
+ coordinates: [[...geometry.coordinates]],
889
+ };
890
+ closePolygons(polygon);
891
+ return polygon;
892
+ }
893
+ return undefined;
894
+ }
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
+ }
870
914
  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 });
871
915
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: GoogleMapsService });
872
916
  }
@@ -950,6 +994,39 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImpo
950
994
  type: Injectable
951
995
  }], ctorParameters: () => [{ type: GoogleMapsService }, { type: i0.Injector }, { type: i0.ViewContainerRef }] });
952
996
 
997
+ class TheSeamGoogleMapsDrawButtonControlComponent {
998
+ _googleMaps = inject(GoogleMapsService);
999
+ _data = inject(MAP_CONTROL_DATA, { optional: true });
1000
+ _active = toSignal(this._googleMaps.drawing$, {
1001
+ initialValue: false,
1002
+ });
1003
+ label = this._data?.label ?? 'Draw Field';
1004
+ icon = this._data?.icon ?? faDrawPolygon;
1005
+ _onClick() {
1006
+ if (this._googleMaps.isDrawing()) {
1007
+ this._googleMaps.stopDrawing();
1008
+ }
1009
+ else {
1010
+ this._googleMaps.startDrawing();
1011
+ }
1012
+ }
1013
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: TheSeamGoogleMapsDrawButtonControlComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1014
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.15", type: TheSeamGoogleMapsDrawButtonControlComponent, isStandalone: true, selector: "button[seam-google-maps-draw-button-control]", host: { attributes: { "type": "button" }, listeners: { "click": "_onClick()" }, properties: { "attr.draggable": "false", "attr.aria-label": "label", "attr.title": "label", "attr.aria-pressed": "_active()", "class.active": "_active()" }, classAttribute: "gmnoprint gm-control-active" }, ngImport: i0, template: "<seam-icon [icon]=\"icon\" iconType=\"image-fill\"></seam-icon>\n", styles: [":host{box-sizing:border-box;position:absolute;height:40px;width:40px;margin:10px;padding:8px;border:0px;border-radius:2px;background:none #fff;box-shadow:#0000004d 0 1px 4px -1px;color:#666;text-transform:none;appearance:none;cursor:pointer;-webkit-user-select:none;user-select:none;overflow:hidden}:host.active{color:#000;box-shadow:#0000004d 0 1px 4px -1px,inset 0 0 0 2px #000}\n"], dependencies: [{ kind: "ngmodule", type: TheSeamIconModule }, { kind: "component", type: i2.IconComponent, selector: "seam-icon", inputs: ["grayscaleOnDisable", "disabled", "iconClass", "icon", "size", "showDefaultOnError", "defaultIcon", "iconType"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1015
+ }
1016
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: TheSeamGoogleMapsDrawButtonControlComponent, decorators: [{
1017
+ type: Component,
1018
+ args: [{ selector: 'button[seam-google-maps-draw-button-control]', imports: [TheSeamIconModule], host: {
1019
+ '(click)': '_onClick()',
1020
+ '[attr.draggable]': 'false',
1021
+ '[attr.aria-label]': 'label',
1022
+ '[attr.title]': 'label',
1023
+ '[attr.aria-pressed]': '_active()',
1024
+ '[class.active]': '_active()',
1025
+ type: 'button',
1026
+ class: 'gmnoprint gm-control-active',
1027
+ }, changeDetection: ChangeDetectionStrategy.OnPush, template: "<seam-icon [icon]=\"icon\" iconType=\"image-fill\"></seam-icon>\n", styles: [":host{box-sizing:border-box;position:absolute;height:40px;width:40px;margin:10px;padding:8px;border:0px;border-radius:2px;background:none #fff;box-shadow:#0000004d 0 1px 4px -1px;color:#666;text-transform:none;appearance:none;cursor:pointer;-webkit-user-select:none;user-select:none;overflow:hidden}:host.active{color:#000;box-shadow:#0000004d 0 1px 4px -1px,inset 0 0 0 2px #000}\n"] }]
1028
+ }] });
1029
+
953
1030
  /**
954
1031
  *
955
1032
  */
@@ -1359,6 +1436,10 @@ class TheSeamGoogleMapsComponent extends _TheSeamGoogleMapsMixinBase {
1359
1436
  data: { label: 'Center on Field', icon: faCrosshairs },
1360
1437
  position: 9 /* google.maps.ControlPosition.RIGHT_BOTTOM */,
1361
1438
  };
1439
+ _drawControlDef = {
1440
+ component: TheSeamGoogleMapsDrawButtonControlComponent,
1441
+ position: 1 /* google.maps.ControlPosition.TOP_LEFT */,
1442
+ };
1362
1443
  _focusOrigin = null;
1363
1444
  set value(value) {
1364
1445
  this._mapValueManager.setValue(value, MapValueSource.Input);
@@ -1579,7 +1660,7 @@ class TheSeamGoogleMapsComponent extends _TheSeamGoogleMapsMixinBase {
1579
1660
  useExisting: forwardRef(() => TheSeamGoogleMapsComponent),
1580
1661
  multi: true,
1581
1662
  },
1582
- ], 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", 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 });
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 });
1583
1664
  }
1584
1665
  __decorate([
1585
1666
  InputBoolean()
@@ -1625,7 +1706,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImpo
1625
1706
  useExisting: forwardRef(() => TheSeamGoogleMapsComponent),
1626
1707
  multi: true,
1627
1708
  },
1628
- ], 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", 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"] }]
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"] }]
1629
1710
  }], ctorParameters: () => [{ type: i0.ElementRef }, { type: i1.FocusMonitor }, { type: GoogleMapsService }, { type: MapValueManagerService }, { type: TheSeamGoogleMapsApiLoader }], propDecorators: { value: [{
1630
1711
  type: Input
1631
1712
  }], tabIndex: [{
@@ -2244,5 +2325,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImpo
2244
2325
  * Generated bundle index. Do not edit.
2245
2326
  */
2246
2327
 
2247
- 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, addInnerFeatureCutoutToExteriorFeature, createDataFeatureFromPolygon, createFeatureChangeObservable, featureContains, fixPathDifferentStartingAndEndingPoint, getBoundsWithAllFeatures, getFeatureBounds, getFeatureCenter, getFeaturesCount, getHoveredStyleOptionsDefinedByFeature, getPossibleExteriorFeature, getSelectedStyleOptionsDefinedByFeature, getStyleOptionsDefinedByFeature, isAppFeatureProperty, isFeatureSelected, multiPolygonCoordinates, polygonCoordinates, polygonHasValidPathsLengths, removeAllFeatures, setFeatureSelected, stripAppFeaturePropertiesFromJson, toTurfJsFeature, toTurfJsMultiPolygon, toTurfJsPolygon };
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 };
2248
2329
  //# sourceMappingURL=theseam-ui-common-google-maps.mjs.map