Bing Maps With Angular in a Spring Boot Application
How to integrate Bing Maps with Angular to show different site properties at different points in time with a Spring Boot backend.
Join the DZone community and get the full member experience.
Join For FreeThe AngularAndSpringWithMaps project shows how to integrate Bing Maps, Angular, and Spring Boot with a Gradle build. The property data of the sites is stored with JPA in H2/PostgreSQL databases.
The purpose of the AngularAndSpringWithMaps project is to show the site properties at different points in time. To choose the site and then choose the time and have the site properties displayed in a map. New properties can be added and deleted on the map and then persisted. This article will show how to store and display company sites.
The Backend
The site properties are stored in these Entities:
- CompanySite -> the site that contains the properties at the location for the year. All the necessary contained entities are loaded.
- Polygon -> a property at the CompanySite with multiple Rings; a Polygon can contain holes.
- Ring -> a ring of location points that makes up a property or a hole in a property (could be a lake).
- Location -> a location point of a ring.
The initial test data is provided by Liquibase with the files in this directory. For information about setting up Liquibase with Spring Boot, these articles (article 1, article 2) help a lot. For loading the initial data this article can help.
The CompanySiteRepository to get the site for the year:
xxxxxxxxxx
public interface CompanySiteRepository extends JpaRepository<CompanySite, Long>{
("select cs from CompanySite cs where lower(cs.title) like %:title% and cs.atDate >= :from and cs.atDate <= :to")
List<CompanySite> findByTitleFromTo( ("title") String title, ("from") LocalDate from, ("to") LocalDate to);
}
In lines 2-3, the companySite
with a name containing the name string and the year is selected.
The company sites are loaded with the CompanySiteService:
xxxxxxxxxx
public List<CompanySite> findCompanySiteByTitleAndYear(String title, Long year) {
if (title == null || title.length() < 2) {
return List.of();
}
LocalDate beginOfYear = LocalDate.of(year.intValue(), 1, 1);
LocalDate endOfYear = LocalDate.of(year.intValue(), 12, 31);
return this.companySiteRepository
.findByTitleFromTo(title.toLowerCase(), beginOfYear, endOfYear)
.stream().peek(companySite -> this.orderCompanySite(companySite))
.collect(Collectors.toList());
}
private CompanySite orderCompanySite(CompanySite companySite) {
companySite.getPolygons()
.forEach(polygon -> polygon.getRings()
.forEach(ring -> ring.setLocations(new LinkedHashSet<Location>(ring.getLocations().stream() .sorted((Location l1, Location l2) -> l1.getOrderId().compareTo(l2.getOrderId()))
.collect(Collectors.toList())))));
return companySite;
}
In lines 2-6, empty titles or titles shorter than two characters are filtered out and the beginning and end of the year are set.
In lines 7-10, the companySite
are selected from the DB and the entity tree is ordered with the orderCompanySite
method.
In lines 13-18, the companySite
entity locations are ordered to get the property borders. This is done in code because the locations are selected by JPA.
The REST endpoint is implemented in CompanySiteController:
xxxxxxxxxx
"rest/companySite") (
public class CompanySiteController {
private static final Logger LOGGER = LoggerFactory.getLogger(CompanySite.class);
private final CompanySiteService companySiteService;
public CompanySiteController(CompanySiteService companySiteService) {
this.companySiteService = companySiteService;
}
(value = "/title/{title}/year/{year}",
method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<List<CompanySiteDto>> getCompanySiteByTitle(
"title") String title, ("year") Long year) { (
List<CompanySiteDto> companySiteDtos = this.companySiteService.
findCompanySiteByTitleAndYear(title, year)
.stream().map(companySite ->
EntityDtoMapper.mapToDto(companySite))
.collect(Collectors.toList());
return new ResponseEntity<List<CompanySiteDto>>(
companySiteDtos, HttpStatus.OK);
}
...
}
In lines 1-3, the base REST endpoint is defined.
In lines 5-9, the CompanySiteService
is injected with the constructor.
In lines 11-14, the REST endpoint to get a company site by title
and year
is defined. The variables are defined in the @RequestMapping
and read in parameters with @PathVariable
.
In lines 15-19, the companySites
are read with the CompanySiteService
and then mapped in a stream with the EntityToDtoMapper to dtos for the front-end.
In lines 20-21, the result is returned in a ResponseEntity
with the HTTP status ok
.
The Front-End
The documentation for Bing Maps can be found here. Bing provides types for the API. Bing Maps support the data structures polygon/ring/location that are supported in the backend. For the components Material is used.
In the package.json file, the libraries are built for Ivy postinstall and the types are added:
xxxxxxxxxx
"scripts": {
"ng": "ng",
"start": "ng serve --hmr --proxy-config proxy.conf.js",
"build": "ng build --prod --localize",
"test": "ng test --browsers ChromeHeadless --watch=false",
"test-chromium": "ng test --browsers ChromiumHeadless --watch=false",
"test-local": "ng test --browsers Chromium --watch=true",
"lint": "ng lint",
"e2e": "ng e2e",
"postinstall": "ngcc",
"prebuild": "rimraf ./dist && mkdirp ./dist",
"postbuild": "npm run deploy",
"predeploy": "rimraf ../../main/resources/static/*",
"deploy": "cpx 'base/*' dist/testproject/ && cpx 'dist/testproject/**' ../../main/resources/static/"
},
"private": true,
"dependencies": {
"bingmaps": "^2.0.3",
}
In line 10, the libraries are built for Ivy after the install.
In line 19, the types for bingmaps
are added for TypeScript.
The map is displayed in the CompanySite component with this template:
xxxxxxxxxx
<div>
<form class="example-form" [formGroup]="componentForm">
<div class="form-container form-container-input">
<div>
<div>
<mat-form-field class="example-full-width">
<input type="text" placeholder="Pick one"
aria-label="Number" matInput
formControlName="{{COMPANY_SITE}}"
[matAutocomplete]="auto">
<mat-autocomplete autoActiveFirstOption
#auto="matAutocomplete"
[displayWith]="displayTitle">
<mat-option
*ngFor="let option of companySiteOptions | async"
[value]="option">
{{option.title}}
</mat-option>
</mat-autocomplete>
</mat-form-field>
</div>
<div>
<mat-slider class="my-slider" thumbLabel
[displayWith]="formatLabel" step="10"
min="1970" max="2020"
formControlName="{{SLIDER_YEAR}}">
</mat-slider>
<span class="my-year"
i18n="@@companysite.slideryear">
Year: {{ componentForm.get('sliderYear').value }}
</span>
</div>
</div>
</div>
...
</form>
<div #bingMap class="bing-map-container"></div>
</div>
In line 2, the formGroup componentForm
is set as reactive form for the component.
In lines 6-20, a <mat-form-field> wraps a <input> with a <mat-autocomplete> feature. The <input> is connected to the formControl companySite
. The <mat-autocomplete> is connected to the input with #auto
and the matAutocomplete
property. The <mat-options> of the <mat-autocomplete> show the titles of the companySiteOptions
observable. That is all that is needed for a typeahead with Material components.
In lines 23-26, <mat-slider> is added to select the year it starts with 1970 and ends with 2020 and is connected to the formControl sliderYear
.
In lines 27-30, a <span> is added to display the year of the slider.
In line 36, a <div> is added with #bingMap
to provide the container that displays the map.
The CompanySite component is set up like this:
xxxxxxxxxx
Component({
selector: 'app-company-site',
templateUrl: './company-site.component.html',
styleUrls: ['./company-site.component.scss']
})
export class CompanySiteComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('bingMap')
bingMapContainer: ElementRef;
newLocations: NewLocation[] = [];
map: Microsoft.Maps.Map = null;
resetInProgress = false;
companySiteOptions: Observable<CompanySite[]>;
componentForm = this.formBuilder.group({
companySite: ['Finkenwerder', Validators.required],
sliderYear: [2020],
property: ['add Property', Validators.required]
});
readonly COMPANY_SITE = 'companySite';
readonly SLIDER_YEAR = 'sliderYear';
readonly PROPERTY = 'property';
private mainConfiguration: MainConfiguration = null;
private readonly containerInitSubject = new Subject<Container>();
private containerInitSubjectSubscription: Subscription;
private companySiteSubscription: Subscription;
private sliderYearSubscription: Subscription;
constructor(public dialog: MatDialog,
private formBuilder: FormBuilder,
private bingMapsService: BingMapsService,
private companySiteService: CompanySiteService,
private configurationService: ConfigurationService) { }
}
In lines 1-6, the Component is defined with the needed live cycle callback interfaces.
In lines 7-8, the elementRef
for the map is injected.
In line 11, the map
property is defined.
In line 14, the observable for the autocomplete input is defined.
In lines 15-19, the reactive form with initial values is created.
In line 25, the containerInitSubject
is created that emits when the initial values are available.
In lines 26-28, the hot subscriptions are defined to be unsubscribed in the onDestroy
.
In lines 30-34, the needed elements get injected in the constructor. The FormBuilder
has been used to create the reactive form.
To read the companySite data the CompanySiteService is used:
xxxxxxxxxx
@Injectable()
export class CompanySiteService {
constructor(private http: HttpClient) { }
//...
public findByTitleAndYear(title: string, year: number):
Observable<CompanySite[]> {
return this.http.
get<CompanySite[]>(`/rest/companySite/title/${title}/year/${year}`);
}
//...
}
This is a simple service that uses the HttpClient
to get the matching CompanySites
for the title and year.
The BingMapsService is used to load the newest version and initialize the mapcontrol
component from Bing:
x
@Injectable({
providedIn: 'root',
})
export class BingMapsService {
private initialized = false;
public initialize(apiKey: string): Observable<boolean> {
if (this.initialized) {
return of(true);
}
const callBackName = `bingmapsLib${new Date().getMilliseconds()}`;
const scriptUrl = `https://www.bing.com/api/maps/mapcontrol?callback=${callBackName}&key=${apiKey}`;
const script = document.createElement('script');
script.type = 'text/javascript';
script.async = true;
script.defer = true;
script.src = scriptUrl;
script.charset = 'utf-8';
document.head.appendChild(script);
const scriptPromise = new Promise<boolean>((resolve, reject) => {
(window)[callBackName] = () => {
this.initialized = true;
resolve(true);
};
script.onerror = (error: Event) => {
console.log(error);
reject(false);
};
});
return from(scriptPromise);
}
}
In lines 1-5, the singleton service is defined with the initialized
property set to false.
In lines 8-10, it checks if Bing Maps has already been initialized.
In lines 11-12, a unique callback name for the successful load of Bing Maps is created and the URL with the name and apiKey
is set.
In lines 13-19, the new script tag in the document head is created.
In lines 20-29, the scriptPromise
is setup. The Bing Maps callback resolves and sets initialized to true. The onerror
script callback logs and rejects.
In line 30, an observable is created from the promise and gets returned.
The CompanySite is initialized like this:
xxxxxxxxxx
constructor(public dialog: MatDialog,
private formBuilder: FormBuilder,
private bingMapsService: BingMapsService,
private companySiteService: CompanySiteService,
private configurationService: ConfigurationService) { }
ngOnInit(): void {
this.companySiteOptions = this.componentForm.valueChanges.pipe(
debounceTime(300),
switchMap(() =>
iif(() => (!this.getCompanySiteTitle()
|| this.getCompanySiteTitle().length < 3
|| !this.componentForm.get(this.SLIDER_YEAR).value),
of<CompanySite[]>([]),
this.companySiteService
.findByTitleAndYear(this.getCompanySiteTitle(),
this.componentForm.get(this.SLIDER_YEAR).value))));
this.companySiteSubscription =
this.componentForm.controls[this.COMPANY_SITE].valueChanges
.pipe(debounceTime(500),
filter(companySite => typeof companySite === 'string'),
switchMap(companySite =>
this.companySiteService.
findByTitleAndYear((companySite as CompanySite).title,
this.componentForm.
controls[this.SLIDER_YEAR].value as number)),
filter(companySite => companySite?.length
&& companySite?.length > 0))
.subscribe(companySite => this.updateMap(companySite[0]));
this.sliderYearSubscription =
this.componentForm.controls[this.SLIDER_YEAR].valueChanges
.pipe(debounceTime(500),
filter(year => !(typeof this.componentForm.
get(this.COMPANY_SITE).value === 'string')),
switchMap(year => this.companySiteService.
findByTitleAndYear(this.getCompanySiteTitle(),
year as number)),
filter(companySite => companySite?.length
&& companySite.length > 0
&& companySite[0].polygons.length > 0))
.subscribe(companySite => this.updateMap(companySite[0]));
forkJoin([this.configurationService.importConfiguration(),
this.companySiteService.findByTitleAndYear(this.getCompanySiteTitle(),
this.componentForm.
controls[this.SLIDER_YEAR].value)])
.subscribe(values => {
this.mainConfiguration = values[0];
this.containerInitSubject.next({ companySite: values[1][0],
mainConfiguration: values[0] } as Container);
});
}
ngAfterViewInit(): void {
this.containerInitSubjectSubscription =
this.containerInitSubject
.pipe(filter(myContainer => !!myContainer
&& !!myContainer.companySite
&& !!myContainer.companySite.polygons
&& !!myContainer.mainConfiguration),
flatMap(myContainer => this.bingMapsService
.initialize(myContainer.mainConfiguration.mapKey)
.pipe(flatMap(() => of(myContainer)))))
.subscribe(container => {
const mapOptions = container.companySite.polygons.length < 1 ?
{} as Microsoft.Maps.IMapLoadOptions
: {
center: new Microsoft.Maps.Location(
container.companySite.
polygons[0].centerLocation.latitude,
container.companySite.
polygons[0].centerLocation.longitude)
} as Microsoft.Maps.IMapLoadOptions;
this.map = new Microsoft.Maps.Map(
this.bingMapContainer.nativeElement as HTMLElement,
mapOptions);
this.componentForm.
controls[this.COMPANY_SITE].setValue(container.companySite);
container.companySite.polygons.
forEach(polygon => this.addPolygon(polygon));
Microsoft.Maps.Events.
addHandler(this.map, 'click', (e) => this.onMapClick(e));
});
}
ngOnDestroy(): void {
this.containerInitSubject.complete();
this.containerInitSubjectSubscription.unsubscribe();
this.companySiteSubscription.unsubscribe();
this.sliderYearSubscription.unsubscribe();
this.map.dispose();
}
private updateMap(companySite: CompanySite): void {
if (this.map) {
this.map.setOptions({
center: new Microsoft.Maps.Location(
companySite.polygons[0].centerLocation.latitude,
companySite.polygons[0].centerLocation.longitude),
} as Microsoft.Maps.IMapLoadOptions);
this.map.entities.clear();
companySite.polygons.forEach(polygon => this.addPolygon(polygon));
}
}
ngOnInit Method
In lines 8-17, the mat-autocomplete
is the service call that gets the options for the auto complete. The reactive form refreshes the options on value change and debounces it. Then there is a minimum validity check and either the matching companySites
are retrieved from the server or the empty observable is returned.
In lines 18-29, the newly selected companySites
of the auto complete are debounced and filtered for selected sites. Then they are retrieved from the server and filtered for empty responses. Then the updateMap
method is called to display the new site on the map.
In lines 30-41, the values of the mat-slider
component are debounced and there is a filter that ensures that it only works if a companySite
is selected. Then the companySites
for the selected year are retrieved from the server and are filtered for empty responses. Then the updateMap
method is called to display the site at the selected year on the map.
In lines 42-50, forkJoin
is used to send the requests for the MainConfigration with the Bing Maps key and the initial CompanySite
concurrently. The responses come in an array where the mainConfiguration
is in first element and the companySites in the second. They are set in the property and in the Container object. The Container object is send with next in the containerInitSubject
.
ngAfterViewInit Method
In the ngAfterViewInit
method, the the map of bing is initialized because the view has to be available for it. In lines 54-59, the containerInitSubject
is used to make sure the companySite
and the configuration is send. A validation check is done. Then flatMap
is used with the BingMapsService
is used to initialize the map.
In lines 64-73, the IMapLoadOptions
interface is created with the current center for the new Map.
In lines 73-75, the new map is created on the bingContainer
property with the IMapLoadOptions
.
In line 77, the companySite
is set in the form.
In lines 78-79, the the polygons are updated for the map.
In lines 80-81, the listener for clicks on the map is set.
ngOnDestroy Method
In lines 86-90, the containerInitSubject
gets cleared with next and the hot subscribtions are unsubscribed. The the map gets disposed.
updateMap Method
In line 93, it is checked that the map is initialized.
In lines 94-98, the IMapLoadOptions
of the Bing Map are updated.
In lines 99-100, the entities of the map are cleared and the polygons are recreated.
Conclusion
Spring Boot with JPA and H2/Postgresql makes storing map data easy.
Bing Maps provides nice types for the TypeScript interface. That makes it much easier to use the API and with the types the API is easy to understand. Integrating Bing Maps was pretty straight forward. It needs to be added in the ngAfterViewInit
because the ElementRef
has to be available. The Angular Material components are easy to use and to integrate with RxJS and reactive forms.
The next article will describe how to to add and remove properties to a companySite
by clicking on the map.
Finally
The testdata for the year 2020 shows the property of a factory where the product of the article image is made.
Opinions expressed by DZone contributors are their own.
Comments