diff --git a/angular.json b/angular.json index 43dfc4fe9..77b9ed1e9 100644 --- a/angular.json +++ b/angular.json @@ -26,7 +26,7 @@ "polyfills": "src/polyfills.ts", "tsConfig": "tsconfig.app.json", "assets": ["src/favicon.ico", "src/assets"], - "styles": ["src/styles.scss"], + "styles": ["node_modules/leaflet/dist/leaflet.css", "src/styles.scss"], "scripts": [], "allowedCommonJsDependencies": [ "emoji-flags", diff --git a/package-lock.json b/package-lock.json index 9e6134791..199adfc15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "d3-time-format": "^3.0.0", "d3-transition": "^3.0.1", "emoji-flags": "^1.2.0", + "leaflet": "^1.9.4", "moment-timezone": "^0.5.40", "ng-in-viewport": "^6.1.5", "ngx-moment": "^5.0.0", @@ -65,6 +66,7 @@ "@types/jasmine": "^3.6.0", "@types/jasminewd2": "~2.0.3", "@types/json-schema": "^7.0.4", + "@types/leaflet": "^1.9.3", "@types/node": "^12.11.1", "@typescript-eslint/eslint-plugin": "5.11.0", "@typescript-eslint/parser": "5.11.0", @@ -3925,6 +3927,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.10", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz", + "integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==", + "dev": true + }, "node_modules/@types/http-proxy": { "version": "1.17.7", "dev": true, @@ -3951,6 +3959,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.3.tgz", + "integrity": "sha512-Caa1lYOgKVqDkDZVWkto2Z5JtVo09spEaUt2S69LiugbBpoqQu92HYFMGUbYezZbnBkyOxMNPXHSgRrRY5UyIA==", + "dev": true, + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "12.19.9", "dev": true, @@ -10668,6 +10685,11 @@ "node": ">= 8" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==" + }, "node_modules/less": { "version": "4.1.2", "dev": true, @@ -19909,6 +19931,12 @@ "version": "0.0.50", "dev": true }, + "@types/geojson": { + "version": "7946.0.10", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz", + "integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==", + "dev": true + }, "@types/http-proxy": { "version": "1.17.7", "dev": true, @@ -19931,6 +19959,15 @@ "version": "7.0.6", "dev": true }, + "@types/leaflet": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.3.tgz", + "integrity": "sha512-Caa1lYOgKVqDkDZVWkto2Z5JtVo09spEaUt2S69LiugbBpoqQu92HYFMGUbYezZbnBkyOxMNPXHSgRrRY5UyIA==", + "dev": true, + "requires": { + "@types/geojson": "*" + } + }, "@types/node": { "version": "12.19.9", "dev": true @@ -24306,6 +24343,11 @@ "version": "2.0.5", "dev": true }, + "leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==" + }, "less": { "version": "4.1.2", "dev": true, diff --git a/package.json b/package.json index 140b8ff56..11032e543 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "d3-time-format": "^3.0.0", "d3-transition": "^3.0.1", "emoji-flags": "^1.2.0", + "leaflet": "^1.9.4", "moment-timezone": "^0.5.40", "ng-in-viewport": "^6.1.5", "ngx-moment": "^5.0.0", @@ -87,6 +88,7 @@ "@types/jasmine": "^3.6.0", "@types/jasminewd2": "~2.0.3", "@types/json-schema": "^7.0.4", + "@types/leaflet": "^1.9.3", "@types/node": "^12.11.1", "@typescript-eslint/eslint-plugin": "5.11.0", "@typescript-eslint/parser": "5.11.0", diff --git a/projects/swimlane/ngx-charts/src/lib/common/legend/legend-entry.component.ts b/projects/swimlane/ngx-charts/src/lib/common/legend/legend-entry.component.ts index e30d095bc..e535a456d 100644 --- a/projects/swimlane/ngx-charts/src/lib/common/legend/legend-entry.component.ts +++ b/projects/swimlane/ngx-charts/src/lib/common/legend/legend-entry.component.ts @@ -4,7 +4,7 @@ import { Component, Input, Output, ChangeDetectionStrategy, HostListener, EventE selector: 'ngx-charts-legend-entry', template: ` - + {{ trimmedLabel }} @@ -17,6 +17,7 @@ export class LegendEntryComponent { @Input() label: string; @Input() formattedLabel: string; @Input() isActive: boolean = false; + @Input() isIncluded: boolean = true; @Output() select: EventEmitter = new EventEmitter(); @Output() activate: EventEmitter<{ name: string }> = new EventEmitter(); diff --git a/projects/swimlane/ngx-charts/src/lib/common/legend/legend.component.ts b/projects/swimlane/ngx-charts/src/lib/common/legend/legend.component.ts index b082067e4..5c470a3fa 100644 --- a/projects/swimlane/ngx-charts/src/lib/common/legend/legend.component.ts +++ b/projects/swimlane/ngx-charts/src/lib/common/legend/legend.component.ts @@ -33,6 +33,7 @@ export interface LegendEntry { [formattedLabel]="entry.formattedLabel" [color]="entry.color" [isActive]="isActive(entry)" + [isIncluded]="isIncluded(entry)" (select)="labelClick.emit($event)" (activate)="activate($event)" (deactivate)="deactivate($event)" @@ -53,7 +54,8 @@ export class LegendComponent implements OnChanges { @Input() colors: ColorHelper; @Input() height: number; @Input() width: number; - @Input() activeEntries; + @Input() activeEntries: any[]; + @Input() includedEntries: any[]; @Input() horizontal = false; @Output() labelClick: EventEmitter = new EventEmitter(); @@ -102,6 +104,14 @@ export class LegendComponent implements OnChanges { return item !== undefined; } + isIncluded(entry: LegendEntry): boolean { + if (!this.includedEntries) return true; + const item = this.includedEntries.find(d => { + return entry.label === d; + }) + return item !== undefined; + } + activate(item: { name: string }) { this.labelActivate.emit(item); } diff --git a/src/app/app.component.html b/src/app/app.component.html index 6dd4b53cc..341bd3039 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -597,6 +597,25 @@ (select)="onSelect($event)" > + +
{{ barChart | json }}
{{ lineChartSeries | json }}
{{ timelineFilterBarData | json }}
+
{{ mapChartData | json }}
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +

Documentation

diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 9e4084f74..fda58d2cf 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, ViewEncapsulation } from '@angular/core'; +import { Component, OnInit, ViewEncapsulation, ViewChild } from '@angular/core'; import { Location, LocationStrategy, HashLocationStrategy } from '@angular/common'; import * as shape from 'd3-shape'; import * as d3Array from 'd3-array'; @@ -25,6 +25,7 @@ import pkg from '../../projects/swimlane/ngx-charts/package.json'; import { InputTypes } from '@swimlane/ngx-ui'; import { LegendPosition } from '@swimlane/ngx-charts/common/types/legend.model'; import { ScaleType } from '@swimlane/ngx-charts/common/types/scale-type.enum'; +import { MapChartComponent } from './custom-charts/map-chart/map-chart.component'; const monthName = new Intl.DateTimeFormat('en-us', { month: 'short' }); const weekdayName = new Intl.DateTimeFormat('en-us', { weekday: 'short' }); @@ -117,6 +118,10 @@ export class AppComponent implements OnInit { strokeColor: string = '#FFFFFF'; strokeWidth: number = 2; wrapTicks = false; + latitude: number = 39.8282; + longitude: number = -98.5795; + mapLanguage: string = 'native'; + centerMapAt: boolean = false; curves = { Basis: shape.curveBasis, @@ -256,6 +261,46 @@ export class AppComponent implements OnInit { { value: 33000, name: 'Minimum' } ]; + mapChartData = [ + { + name: 'United States', + series: [ + { + name: 'New York', + value: [40.7128, -74.0060] + }, + { + name: 'Austin', + value: [30, -97.7431] + }, + { + name: 'Los Angeles', + value: [34.0522, -118.2437] + } + ] + }, + { + name: 'International', + series: [ + { + name: 'Tokyo', + value: [35.6895, 139.6917] + }, + { + name: 'Sydney', + value: [-33.8688, 151.2093] + }, + { + name: 'Guangzhou', + value: [23.1291, 113.2644] + } + ] + } + ]; + mapZoom = 3; + initCoordX = 39.8282; + initCoordY = -98.5795; + // data plotData: any; @@ -265,6 +310,8 @@ export class AppComponent implements OnInit { dimVisible: boolean = true; optsVisible: boolean = true; + @ViewChild(MapChartComponent) mapComponent: MapChartComponent; + constructor(public location: Location) { this.mathFunction = this.getFunction(); @@ -482,6 +529,10 @@ export class AppComponent implements OnInit { this.view = [this.width, this.height]; } + mapChangePosition() { + this.mapComponent.changePosition(); + } + toggleFitContainer() { if (this.fitContainer) { this.view = undefined; diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 4e0067762..952fdb863 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -10,6 +10,7 @@ import { NgxChartsModule } from '@swimlane/ngx-charts/ngx-charts.module'; import { NgxUIModule } from '@swimlane/ngx-ui'; import { ComboChartComponent, ComboSeriesVerticalComponent } from './custom-charts/combo-chart'; import { BubbleChartInteractiveModule } from './custom-charts/bubble-chart-interactive'; +import { MapChartComponent } from './custom-charts/map-chart/map-chart.component'; @NgModule({ providers: [ @@ -31,7 +32,8 @@ import { BubbleChartInteractiveModule } from './custom-charts/bubble-chart-inter SparklineComponent, TimelineFilterBarChartComponent, ComboChartComponent, - ComboSeriesVerticalComponent + ComboSeriesVerticalComponent, + MapChartComponent ], bootstrap: [AppComponent] }) diff --git a/src/app/chartTypes.ts b/src/app/chartTypes.ts index 5e902dddd..6431be59e 100644 --- a/src/app/chartTypes.ts +++ b/src/app/chartTypes.ts @@ -704,6 +704,25 @@ const chartGroups = [ 'tooltipDisabled' ] }, + { + name: 'Map Chart', + selector: 'map-chart', + inputFormat: 'mapChart', + options: [ + 'showLegend', + 'legendTitle', + 'legendPosition', + 'colorScheme', + 'mapZoom', + 'initCoordX', + 'initCoordY', + 'view', + 'centerMapAt', + 'latitude', + 'longitude', + 'mapLanguage' + ] + }, { name: 'Heat Map - Calendar', selector: 'calendar', diff --git a/src/app/custom-charts/map-chart/map-chart.component.scss b/src/app/custom-charts/map-chart/map-chart.component.scss new file mode 100644 index 000000000..5014733f6 --- /dev/null +++ b/src/app/custom-charts/map-chart/map-chart.component.scss @@ -0,0 +1,22 @@ +.map-container { + display: flex; + gap: 30px; + + #map { + height: 100%; + + .map-tooltip { + background: black; + color: white; + border: none; + } + + .leaflet-tooltip-top::before, + .leaflet-tooltip-bottom::before, + .leaflet-tooltip-left::before, + .leaflet-tooltip-right::before { + border-top-color: black; + } + + } +} \ No newline at end of file diff --git a/src/app/custom-charts/map-chart/map-chart.component.ts b/src/app/custom-charts/map-chart/map-chart.component.ts new file mode 100644 index 000000000..f1bd2ff17 --- /dev/null +++ b/src/app/custom-charts/map-chart/map-chart.component.ts @@ -0,0 +1,274 @@ +import { + Component, + Input, + ViewEncapsulation, + Output, + EventEmitter, + OnInit +} from '@angular/core'; + +import { + BaseChartComponent, + ViewDimensions, + ColorHelper, + calculateViewDimensions, +} from 'projects/swimlane/ngx-charts/src/public-api'; +import { LegendPosition } from '../../../../projects/swimlane/ngx-charts/src/lib/common/types/legend.model'; +import * as L from 'leaflet'; +import {select} from 'd3-selection'; + +@Component({ + selector: 'map-chart-component', + template: ` +
+
+ + +
+ `, + styleUrls: ['./map-chart.component.scss', '../../../../projects/swimlane/ngx-charts/src/lib/common/base-chart.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class MapChartComponent extends BaseChartComponent implements OnInit { + @Input() legend = false; + @Input() legendTitle: string = 'Legend'; + @Input() legendPosition: string = 'right'; + @Input() mapZoom: number; + @Input() initCoordX: any; + @Input() initCoordY: any; + @Input() view: [number, number]; + @Input() longitude: number; + @Input() latitude: number; + @Input() mapLanguage: string = "native"; + @Input() centerMapAt: any; + @Input() mapLog: boolean = false; + + @Output() activate: EventEmitter = new EventEmitter(); + @Output() deactivate: EventEmitter = new EventEmitter(); + + dims: ViewDimensions; + transform: string; + colors: ColorHelper; + margin: any[] = [10, 20, 10, 20]; + legendOptions: any; + legendWidth + mapInitialize: boolean = false; + map: any; + markersLayer: any; + domain: any[]; + filteredDomain: any[]; + includedEntries: any[]; + currentTiles = null; + + readonly LegendPosition = LegendPosition; + + + trackBy(index, item): string { + return `${item.name}`; + } + + update(): void { + super.update(); + this.dims = calculateViewDimensions({ + width: this.width, + height: this.height, + margins: this.margin, + showXAxis: false, + showYAxis: false, + showXLabel: false, + showYLabel: false, + showLegend: this.legend, + legendType: this.schemeType, + legendPosition: this.legendPosition as any + }); + + this.domain = this.getDomain(); + if (!this.filteredDomain) { + this.filteredDomain = this.domain; + } + + this.colors = new ColorHelper(this.scheme, this.schemeType, this.domain, this.customColors); + + this.addMarkers(); + this.updateLegend(); + + this.transform = `translate(${this.dims.xOffset} , ${this.margin[0]})`; + + this.adjustSize(); + + if (this.map) { + //trigger the map to reload + this.map.invalidateSize(); + } else { + this.mapInit(); + } + + if (this.mapLanguage) { + this.changeLanguage(); + } + } + + mapInit(): void { + this.map = L.map('map', { + center: [this.initCoordX, this.initCoordY], + zoom: this.mapZoom, + inertia: false + }); + + setTimeout(() => { + this.map.invalidateSize(true); + }, 0); + + this.map.setMaxBounds(this); + + this.currentTiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 18, + minZoom: 2, + attribution: '© OpenStreetMap' + }); + + this.currentTiles.addTo(this.map); + + this.markersLayer = L.layerGroup(); + this.markersLayer.addTo(this.map); + + this.mapInitialize = true; + } + + getDomain(): any[] { + const values = []; + for (const d of this.results) { + values.push(d.name); + } + return values; + } + + addMarkers(): void { + if (!this.mapInitialize) return; + this.markersLayer.clearLayers(); + for (const d of this.results) { + if (this.filteredDomain.includes(d.name)) { + for (const location of d.series) { + const markerHtmlStyles = ` + background-color: ${this.colors.getColor(d.name)}; + width: 2rem; + height: 2rem; + display: block; + left: -1rem; + top: -1rem; + position: relative; + border-radius: 2rem 2rem 0; + transform: rotate(45deg); + border: 1px solid #FFFFFF; + `; + const icon = L.divIcon({ + iconAnchor: [0, 24], + tooltipAnchor: [-6, 0], + popupAnchor: [0, -36], + html: `` + }); + const marker = L.marker(location.value, {icon: icon}); + + marker.bindTooltip(location.name, { + direction: 'top', + offset: L.point(7, -40), + className: 'map-tooltip', + opacity: 0.75 + }).openTooltip(); + + this.markersLayer.addLayer(marker); + } + } + } + } + + changePosition(): void { + this.map.setView([this.latitude, this.longitude]); + } + + updateLegend(): void { + let legendColumns = 0; + if (this.legend) { + if (this.legendPosition === LegendPosition.Right) { + legendColumns = 2; + } + } + + const chartColumns = 12 - legendColumns; + + const chartWidth = Math.floor((this.width * chartColumns) / 12.0); + this.legendWidth = + this.legendPosition === LegendPosition.Right + ? Math.floor((this.width * legendColumns) / 12.0) + : chartWidth; + } + + legendOnClick(data) { + this.select.emit(data); + var index = this.filteredDomain.indexOf(data); + if (index !== -1) { + this.filteredDomain.splice(index, 1); + } + else { + this.filteredDomain.push(data); + } + this.update(); + } + + adjustSize() { + if (this.view) { + this.width = this.dims.width; + this.height = this.dims.height; + } + const container = select(this.chartElement.nativeElement).select('#map').node() as HTMLElement; + container.style.width = this.width + "px"; + container.style.height = this.height + "px"; + } + + changeLanguage() { + const oldCenter = this.map.getCenter(); + + // Remove the current tile layer if it exists + if (this.currentTiles) { + this.map.removeLayer(this.currentTiles); + } + + // Create and add the new tile layer based on mapLanguage + if (this.mapLanguage == "native") { + this.currentTiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 18, + minZoom: 1, + attribution: '© OpenStreetMap' + }); + } else if (this.mapLanguage == "german"){ + this.currentTiles = L.tileLayer('https://{s}.tile.openstreetmap.de/tiles/osmde/{z}/{x}/{y}.png', { + maxZoom: 18, + minZoom: 1, + attribution: '© OpenStreetMap' + }); + } else if (this.mapLanguage == "english") { + this.currentTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png', { + minZoom: 1, + maxZoom: 18, + attribution: '© carto.com contributors' + }); + } + this.currentTiles.addTo(this.map); + + this.map.setView(oldCenter); + } +}