Compare commits

...

7 Commits

8 changed files with 1851 additions and 153 deletions

View File

@@ -18,6 +18,7 @@
"@angular/platform-browser": "^21.2.0", "@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0", "@angular/router": "^21.2.0",
"@bluehalo/ngx-leaflet": "^21.2.0", "@bluehalo/ngx-leaflet": "^21.2.0",
"@turf/turf": "^7.3.5",
"@types/geojson": "^7946.0.16", "@types/geojson": "^7946.0.16",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",

1657
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { Header } from './header/header'; import { Header } from './header/header';
import { Map } from './map/map'; import { OSMMap } from './map/map';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet, Header, Map], imports: [RouterOutlet, Header, OSMMap],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss', styleUrl: './app.scss',
}) })

View File

@@ -1,69 +0,0 @@
import * as G from 'geojson';
// Custom GeoJSON style for the building
export const buildingSytle = {
color: '#d62305', // Red border
weight: 2, // Border width
fillColor: '#d62305', // Red fill
fillOpacity: 0.1, // Semi-transparent fill
};
// Example GeoJSON data
export const examplePolygon: G.Polygon = {
type: 'Polygon',
coordinates: [
[
[8.3897067, 49.0149349], // Southwest corner
[8.3904111, 49.0149078], // Southeast corner
[8.3904345, 49.0151542], // Northeast corner
[8.3897302, 49.0151832], // Northwest corner
[8.3897067, 49.0149349], // Closing the polygon by repeating the first point
],
],
};
export const buildings: G.FeatureCollection = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {
name: 'Gebäude E',
building: 'university',
center: [8.39007, 49.015],
},
geometry: {
type: 'Polygon',
coordinates: [
[
[8.3897067, 49.0149349], // Southwest corner
[8.3904111, 49.0149078], // Southeast corner
[8.3904345, 49.0151542], // Northeast corner
[8.3897302, 49.0151832], // Northwest corner
[8.3897067, 49.0149349], // Closing the polygon by repeating the first point
],
],
},
},
{
type: 'Feature',
properties: {
name: 'Gebäude F',
building: 'university',
center: [8.3901208597194, 49.0156137375335],
},
geometry: {
type: 'Polygon',
coordinates: [
[
[8.3897585, 49.015502], // Southwest corner
[8.3904592, 49.0154731], // Southeast corner
[8.3904833, 49.0157255], // Northeast corner
[8.3897827, 49.0157543], // Northwest corner
[8.3897585, 49.015502], // Closing the polygon by repeating the first point
],
],
},
},
],
};

View File

@@ -7,9 +7,9 @@
(leafletMapReady)="onMapReady($event)" (leafletMapReady)="onMapReady($event)"
></div> ></div>
@if (!isFullscreen && details) { @if (!isFullscreen && details) {
<div class="details"> <div class="details">
<h2>{{ details.name }}</h2> <h2>{{ details.name }}</h2>
<p>{{ details.building }}</p> <p>{{ details.building }}</p>
</div> </div>
} }
</div> </div>

View File

@@ -1,9 +1,10 @@
.map-container { .map-container {
padding: 0; padding: 0;
margin: 0; margin: 0;
position: absolute; position: fixed;
top: 54px; top: 54px;
height: calc(100vh - 54px); height: 100%;
width: 100%;
} }
.map-fullscreen { .map-fullscreen {
@@ -12,14 +13,14 @@
} }
.map-details { .map-details {
left: 10%; left: 25%;
width: 90%; width: 75%;
} }
.details { .details {
position: absolute; position: fixed;
top: 54px; top: 54px;
left: 0; left: 25px;
width: 10%; width: 25%;
height: 100%; height: 100%;
} }

View File

@@ -1,11 +1,12 @@
import { Component, signal } from '@angular/core'; import { Component, signal, OnInit, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { LeafletModule } from '@bluehalo/ngx-leaflet'; import { LeafletModule } from '@bluehalo/ngx-leaflet';
import { center } from '@turf/turf';
import { Overpass } from '../overpass'; import { Overpass } from '../overpass';
import * as L from 'leaflet'; import * as L from 'leaflet';
import * as G from 'geojson';
import * as D from './details'; import * as D from './details';
import * as E from './geojson';
@Component({ @Component({
selector: 'app-map', selector: 'app-map',
@@ -13,92 +14,167 @@ import * as E from './geojson';
templateUrl: './map.html', templateUrl: './map.html',
styleUrl: './map.scss', styleUrl: './map.scss',
}) })
export class Map { export class OSMMap implements OnInit {
private centerLocation = { lat: 49.015514096207895, lng: 8.391567600294243 }; private buildingLayer?: L.LayerGroup;
protected readonly title = signal('HKA-OSM'); protected readonly title = signal('HKA-OSM');
map?: L.Map; map?: L.Map;
details?: D.Details; details?: D.Details;
osm?: L.TileLayer;
options: L.MapOptions = {};
layerControl?: L.Control.Layers;
isFullscreen = true; isFullscreen = true;
osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { constructor(
maxNativeZoom: 19, private overpass: Overpass,
maxZoom: 21, private cdr: ChangeDetectorRef,
minZoom: 18, ) {}
attribution: '© OpenStreetMap contributors',
});
// Define map options ngOnInit() {
options: L.MapOptions = { this.osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
layers: [this.osm], maxNativeZoom: 19,
zoom: 18, // Zoom level maxZoom: 21,
center: L.latLng(this.centerLocation.lat, this.centerLocation.lng), minZoom: 18,
maxBounds: L.latLngBounds( attribution: '© OpenStreetMap contributors',
L.latLng(49.014442, 8.387954), // Southwest corner });
L.latLng(49.017847, 8.395448), // Northeast corner
),
maxBoundsViscosity: 1.0, // Prevent panning outside bounds
};
layerControl = L.control.layers({ // Define map options
OpenStreetMap: this.osm, this.options = {
}); layers: [this.osm],
zoom: 18, // Zoom level
center: this.getCenter(), // Center of the map
};
constructor(private overpass: Overpass) {} this.layerControl = L.control.layers({
OpenStreetMap: this.osm,
});
}
onMapReady(map: L.Map) { onMapReady(map: L.Map) {
this.map = map; this.map = map;
this.layerControl.addTo(this.map); this.layerControl?.addTo(this.map);
this.loadBuildings().addTo(this.map); this.queryBuildingData().addTo(this.map);
this.queryBuildings();
console.log('Map is ready'); console.log('Map is ready');
} }
loadBuildings() { queryBuildingData() {
return L.geoJSON(E.buildings, { // Query the Overpass API for building data and add it to the map as a GeoJSON layer.
style: E.buildingSytle,
onEachFeature: (feature, layer) => {
layer.on({
click: (e) => {
this.isFullscreen = !this.isFullscreen;
if (!this.isFullscreen) {
this.map?.setView(
L.latLng(feature.properties.center[1], feature.properties.center[0]),
20,
);
//FIXME: Disable dragging and keyboard interactions when in fullscreen mode
this.map?.dragging.disable() &&
this.map?.keyboard.disable() &&
this.map?.scrollWheelZoom.disable();
this.details = new D.Details(feature.properties.name, feature.properties.building);
} else {
this.map?.setView(L.latLng(this.centerLocation.lat, this.centerLocation.lng), 18);
//FIXME: Enable dragging and keyboard interactions when in fullscreen mode const buildingLayer = new L.LayerGroup();
this.map?.dragging.enable() &&
this.map?.keyboard.enable() && this.overpass.fetchBuildingData().subscribe((data) => {
this.map?.scrollWheelZoom.enable(); console.log('Received building data:', data);
this.details = undefined; L.geoJSON(this.createFeatureCollection(data), {
} style: { color: '#d62305', weight: 2, fillColor: '#d4caca', fillOpacity: 1 },
console.log('Set isFullscreen to:', this.isFullscreen); onEachFeature: (feature, layer) => {
console.log('Clicked on feature:', feature.properties?.name); layer.on({
}, click: (e) => {
}); this.isFullscreen = !this.isFullscreen;
if (feature.properties && feature.properties.name) { if (!this.isFullscreen) {
layer.bindTooltip(feature.properties.name, { this.map?.setView(this.getCenter(feature.geometry), 20);
permanent: false, this.buildingLayer = this.queryIndoorData(feature.properties.name);
direction: 'top', this.buildingLayer.addTo(this.map!);
this.details = new D.Details(feature.properties.name, feature.properties.building);
} else {
this.map?.setView(this.getCenter(), 18);
if (this.buildingLayer) {
this.map?.removeLayer(this.buildingLayer);
}
}
console.log('Set isFullscreen to:', this.isFullscreen);
console.log('Clicked on feature:', feature.properties?.name);
this.cdr.detectChanges();
},
}); });
} if (feature.properties && feature.properties.name) {
}, layer.bindTooltip(feature.properties.name, {
permanent: false,
direction: 'top',
});
}
},
}).addTo(buildingLayer);
}); });
return buildingLayer;
} }
queryBuildings() { queryIndoorData(building_name: string): L.LayerGroup {
this.overpass.fetchBuildings('E').subscribe((data) => { // Get the building name from the feature properties and query the Overpass API for detailed building data,
console.log('Fetched building data from Overpass API:', data); // including indoor features.
// Here you would typically process the data and add it to the map
// Create a new LayerGroup to hold the building polygons
const resultingLayer = new L.LayerGroup();
this.overpass.fetchIndoorData(building_name).subscribe((data) => {
console.log('Received indoor data for building:', building_name, data);
L.geoJSON(this.createFeatureCollection(data), {
style: { color: '#999999', fillColor: '#e0e0e0', fillOpacity: 1 },
onEachFeature: (feature, layer) => {
if (feature.properties) {
layer.bindTooltip(feature.properties.ref ?? 'Test', {
permanent: false,
direction: 'top',
});
}
},
}).addTo(resultingLayer);
}); });
return resultingLayer;
}
private getCenter(feature?: any): L.LatLng {
// Calculate the center of the feature using Turf.js, or use a default center if no feature is provided.
let centerPoint: number[] = [];
if (feature) {
centerPoint = center(feature).geometry.coordinates.reverse();
} else {
centerPoint = [49.015514096207895, 8.391567600294243];
}
const result = new L.LatLng(centerPoint[0], centerPoint[1]);
return result;
}
private createFeatureCollection(data: any): G.FeatureCollection {
const buildingData: G.FeatureCollection = {
type: 'FeatureCollection',
features: [],
};
// Create a mapping of node IDs to their coordinates
const nodes: Map<number, L.LatLng> = new Map();
data.elements.forEach((element: any) => {
if (element.type === 'node') {
nodes.set(element.id, new L.LatLng(element.lat, element.lon));
}
});
// Process ways and create polygons
// TODO: Change to features, to also add the meta data of the building, e.g. the name of the room
data.elements.forEach((element: any) => {
if (element.type === 'way') {
const coordinates: L.LatLng[] = [];
element.nodes.forEach((nodeId: number) => {
coordinates.push(nodes.get(nodeId)!);
});
buildingData.features.push(
this.createFeature(new Map(Object.entries(element.tags || {})), coordinates),
// this.createFeature(new Map(Object.entries({ name: 'Test' })), coordinates),
);
}
});
return buildingData;
}
private createFeature(properties: Map<string, string>, coordinates: L.LatLng[]): G.Feature {
return {
type: 'Feature',
properties: Object.fromEntries(properties),
geometry: {
type: 'Polygon',
coordinates: [coordinates.map((coord) => [coord.lng, coord.lat])],
},
};
} }
} }

View File

@@ -1,6 +1,25 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
export interface OverpassElementNode {
type: 'node';
id: number;
lat: number;
lon: number;
}
export interface OverpassElementWay {
type: 'way';
id: number;
geometry?: Array<{ lat: number; lon: number }>;
}
export type OverpassElement = OverpassElementNode | OverpassElementWay;
export interface OverpassResponse {
elements: OverpassElement[];
}
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
@@ -9,11 +28,11 @@ export class Overpass {
constructor(private http: HttpClient) {} constructor(private http: HttpClient) {}
fetchBuildings(building_name: string) { fetchIndoorData(building_name: string) {
const header = { 'Content-Type': 'text/plain' }; const header = { 'Content-Type': 'text/plain' };
const query = ` const query = `
[out:json][timeout:25]; [out:json][timeout:90];
nwr["name"="${building_name}"](49.014442, 8.387954, 49.017847, 8.395448)->.building; nwr["building"]["operator"="Hochschule Karlsruhe"]["name"="${building_name}"]->.building;
( (
nwr["indoor"="room"](area.building); nwr["indoor"="room"](area.building);
); );
@@ -21,6 +40,19 @@ export class Overpass {
out body; out body;
`; `;
return this.http.post(this.apiUrl, query, { headers: header }); return this.http.post<OverpassResponse>(this.apiUrl, query, { headers: header });
}
fetchBuildingData() {
const header = { 'Content-Type': 'text/plain' };
const query = `
[out:json][timeout:90];
(
way["building"="university"]["operator"="Hochschule Karlsruhe"];
);
(._;>;);
out body;
`;
return this.http.post<OverpassResponse>(this.apiUrl, query, { headers: header });
} }
} }