Compare commits

..

3 Commits

Author SHA1 Message Date
1c2d245f7c Initial places and map implementation
Some checks failed
CI / main (push) Failing after 4m54s
2025-03-15 12:52:17 +02:00
3abf69edbd Reformat latlon -> coords 2025-03-10 16:34:58 +02:00
5a2c6d12a7 Add overpass template function options
out, timeout, maxsize, bbox, date
2025-03-10 16:34:32 +02:00
13 changed files with 501 additions and 131 deletions

View File

@ -6,22 +6,12 @@ exports.up = function(knex) {
return knex.schema return knex.schema
.createTable('users', function (table) { .createTable('users', function (table) {
table.uuid('id') table.uuid('id').primary().defaultTo(knex.raw(`gen_random_uuid()`));
.primary() table.string('first_name', 255).notNullable();
.defaultTo(knex.raw(`gen_random_uuid()`)); table.string('last_name', 255).notNullable();
table.string('first_name', 255) table.string('email', 255).unique().notNullable();
.notNullable(); table.timestamps(true, true);
table.string('last_name', 255)
.notNullable();
table.string('email', 255)
.unique()
.notNullable();
}) })
// .createTable('products', function (table) {
// table.increments('id');
// table.decimal('price').notNullable();
// table.string('name', 1000).notNullable();
// });
}; };
/** /**
@ -29,7 +19,5 @@ exports.up = function(knex) {
* @returns { Promise<void> } * @returns { Promise<void> }
*/ */
exports.down = function(knex) { exports.down = function(knex) {
return knex.schema return knex.schema.dropTable('users');
// .dropTable('products')
.dropTable('users');
}; };

View File

@ -0,0 +1,23 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function(knex) {
return knex.schema
.createTable('places', function (table) {
table.uuid('id').primary().defaultTo(knex.raw(`gen_random_uuid()`));
table.string('name', 255).notNullable();
table.specificType('coordinates', 'GEOGRAPHY(Point, 4326)').notNullable();
table.string('externalId', 255);
table.timestamps(true, true);
})
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function(knex) {
return knex.schema.dropTable('places');
};

View File

@ -0,0 +1,30 @@
const { fakerLV : faker } = require('@faker-js/faker');
const PLACE_COUNT = 1000;
const BBOX_RIGA = [23.755875,56.815914,24.466553,57.067617];
const [ BBOX_MIN_LAT, BBOX_MIN_LON, BBOX_MAX_LAT, BBOX_MAX_LON ] = BBOX_RIGA;
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.seed = async function(knex) {
const createPlace = () => {
const coords = [
faker.location.latitude({ min: BBOX_MIN_LAT, max: BBOX_MAX_LAT }),
faker.location.longitude({ min: BBOX_MIN_LON, max: BBOX_MAX_LON })
];
return {
name: faker.company.name(),
coordinates: knex.raw('ST_SetSRID(ST_MakePoint(?, ?), 4326)', coords),
}
};
// Deletes ALL existing entries
await knex('places').del()
await knex('places').insert([
...new Array(PLACE_COUNT).fill(() => createPlace()).map((fn, i) => fn(i)),
]);
};

View File

@ -2,6 +2,12 @@ const { fakerLV : faker } = require('@faker-js/faker');
const USER_COUNT = 500; const USER_COUNT = 500;
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.seed = async function(knex) {
const createUser = ({ const createUser = ({
sex = faker.person.sexType(), sex = faker.person.sexType(),
first_name = `${faker.person.firstName(sex)}`, first_name = `${faker.person.firstName(sex)}`,
@ -15,12 +21,6 @@ const createUser = ({
} }
}; };
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.seed = async function(knex) {
// Deletes ALL existing entries
await knex('users').del() await knex('users').del()
await knex('users').insert([ await knex('users').insert([
createUser({ createUser({

View File

@ -1,8 +1,9 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { OverpassService } from '../services/overpass'; // import { AMENITIES, OverpassService } from '../services/overpass';
import { OverpassQuerySchema, OverpassQueryType } from '../schemas/overpass'; import { OverpassQuerySchema, OverpassQueryType } from '../schemas/overpass';
import db from '../../db';
const overpassService = new OverpassService(); // const overpassService = new OverpassService();
export default async function (fastify: FastifyInstance) { export default async function (fastify: FastifyInstance) {
@ -15,23 +16,76 @@ export default async function (fastify: FastifyInstance) {
items: { items: {
type: 'object', type: 'object',
properties: { properties: {
id: { type: 'string' },
title: { type: 'string' }, title: { type: 'string' },
amenity: { type: 'string' }, amenity: { type: 'string' },
tags: { type: 'object', additionalProperties: true } coords: {
type: 'object',
properties: {
lat: { type: 'number' },
lon: { type: 'number' }
}
},
affinity: { type: 'number' },
// tags: { type: 'object', additionalProperties: true }
} }
} }
} }
} }
}, },
config: { config: {
serverCache: { ttl: 10000 } // serverCache: { ttl: 1 * 60 * 1000 }
} }
}, async function (request, reply) { }, async function (request, reply) {
const {lat, lon} = request.query; const {lat, lon} = request.query;
const elements = await overpassService.getAmenitiesAroundLocation({
latlon: { lat, lon }, const knownPlaces = await db.select(
amenities: ['restaurant', 'cafe', 'bar', 'pub', 'biergarten', 'fast_food', 'food_court', 'ice_cream'] 'id',
}); 'name',
reply.send(elements); 'externalId',
db.raw('ST_Y(coordinates::geometry) AS lat'),
db.raw('ST_X(coordinates::geometry) AS lon')
).whereRaw(
'ST_DWithin(coordinates, ST_SetSRID(ST_MakePoint(?, ?), 4326), ?)',
[lon, lat, 2000]
).from('places');
// const externalIds = await db.select('externalId').from('places').where('externalId', 'like', 'osm:%');
// console.log(externalIds);
// overpassService.getAmenitiesAroundLocation({
// radius: 1000,
// coords: { lat, lon },
// amenities: AMENITIES.Sustanance
// }).then(result => {
// const insertItems = result
// .filter(node => {
// if (!node.title || !node.coords) return false;
// return !externalIds.some(({ externalId }) => externalId === `osm:${node.id}`);
// })
// .map(node => ({
// name: node.title,
// externalId: `osm:${node.id}`,
// coordinates: db.raw('ST_SetSRID(ST_MakePoint(?, ?), 4326)', [node.coords.lon, node.coords.lat])
// }));
// if (insertItems.length) db('places').insert(insertItems).then((success) => console.log({success}), (error) => console.log({error}));
// });
const blakus = knownPlaces.map(place => ({
id: place.id,
title: place.name,
amenity: 'pub',
coords: {
lat: place.lat,
lon: place.lon,
},
affinity: Math.floor(Math.random() * 101) // TODO: this is just a placeholder for "affinity" calculation
}));
reply.send(blakus);
}); });
} }

View File

@ -0,0 +1,50 @@
import { FastifyInstance } from 'fastify';
import db from '../../db';
export default async function (fastify: FastifyInstance) {
fastify.get('/places', {
schema: {
response: {
200: {
type: 'object',
properties: {
places: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
coords: {
type: 'object',
properties: {
lat: { type: 'number' },
lon: { type: 'number' }
}
}
}
}
}
}
}
}
},
config: {
// serverCache: { ttl: 10000 }
}
}, async function (request, reply) {
const placesResults = await db.select(
'name',
db.raw('ST_Y(coordinates::geometry) AS lat'),
db.raw('ST_X(coordinates::geometry) AS lon')
).from('places').limit(500);
const places = placesResults.map(({ name, lat, lon}) => ({
name,
coords: { lat, lon }
}));
reply.send({ places });
});
}

View File

@ -1,10 +1,12 @@
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
type OverpassResult = { type OverpassResultElement = NodeElement | WayElement | RelationElement;
type OverpassResult<T = OverpassResultElement> = {
version: number; version: number;
generator: string; generator: string;
osm3s: Record<string, string>; osm3s: Record<string, string>;
elements: (NodeElement | WayElement | RelationElement)[]; elements: T[];
} }
type OverpassLocation = { type OverpassLocation = {
@ -38,18 +40,40 @@ type RelationElement = OverpassElement & {
}[]; }[];
} }
function opq(strings, ...expressions) { type OverpassQuerySettings = {
let query = '[out:json];\n'; out?: 'json' | 'xml' // | 'csv' | 'custom' | 'popup'; // TODO: other options would require additional implementation
timeout?: number;
maxsize?: number;
bbox?: [number, number, number, number];
date?: Date;
}
export const AMENITIES = {
get Sustanance() {
return ['restaurant', 'cafe', 'bar', 'pub', 'biergarten', 'fast_food', 'food_court', 'ice_cream']
}
};
function overpassQueryTemplate ({ out = 'json', timeout, maxsize, bbox = [34.016242,-24.433594,71.663663,48.164063], date}: OverpassQuerySettings, statements: string[] = ['out geom']) {
return function (strings, ...expressions) {
let query = [
`[out:${out}]`,
timeout ? `[timeout:${timeout}]` : '',
maxsize ? `[maxsize:${maxsize}]` : '',
bbox && bbox.length === 4 ? `[bbox:${bbox.join(',')}]` : '',
date ? `[date:${date.toISOString()}]` : ''
].join('') + ';';
for (let i = 0; i < strings.length; i++) { for (let i = 0; i < strings.length; i++) {
query += strings[i]; query += strings[i];
if (i < expressions.length) query += expressions[i]; if (i < expressions.length) query += expressions[i];
} }
query += '\nout body;\n>;\nout skel qt;'; query += statements.join(';') + ';';
return query; return query;
} }
}
export class OverpassService { export class OverpassService {
private axios: AxiosInstance; private axios: AxiosInstance;
@ -58,22 +82,36 @@ export class OverpassService {
this.axios = axios.create({ baseURL }); this.axios = axios.create({ baseURL });
} }
async getAmenitiesAroundLocation({radius = 500, amenities = [], latlon}: {radius?: number, latlon: OverpassLocation, amenities: string[]}) { async getAmenitiesAroundLocation({radius = 500, amenities = [], coords}: {radius?: number, coords: OverpassLocation, amenities: string[]}) {
if (!latlon || !latlon.lat || !latlon.lon) throw new Error('Invalid location'); if (!coords || !coords.lat || !coords.lon) throw new Error('Invalid location');
const { lat, lon } = latlon; const { lat, lon } = coords;
const opq = overpassQueryTemplate({ out: 'json', timeout: 60 });
const query = opq`node(around:${radius}, ${lat}, ${lon})["amenity"~"${amenities.join('|')}"];`; const query = opq`node(around:${radius}, ${lat}, ${lon})["amenity"~"${amenities.join('|')}"];`;
const result = await this.overpassQuery(query); const result = await this.overpassQuery<NodeElement>(query);
return result.elements const output = result.elements
.map(({ tags }) => ({ title: tags.name, amenity: tags.amenity, tags})) .map(item => {
const { tags } = item;
return {
id: item.id,
title: tags.name,
amenity: tags.amenity,
tags,
coords: item.lat && item.lon ? { lat: item.lat, lon: item.lon } : {}
}
})
.filter(i => amenities.includes(i.amenity)); // query returns some random results. need to look into this .filter(i => amenities.includes(i.amenity)); // query returns some random results. need to look into this
return output;
} }
async overpassQuery(query: string): Promise<OverpassResult> { async overpassQuery<T = OverpassResultElement>(query: string): Promise<OverpassResult<T>> {
return this.getData('interpreter', { data: query }); return this.getData('interpreter', { data: query });
} }

View File

@ -12,4 +12,5 @@ project.ext {
// useKotlin = true // useKotlin = true
// kotlinVersion = "1.6.0" // kotlinVersion = "1.6.0"
cartoMobileVersion = '4.4.7'
} }

View File

@ -4,6 +4,7 @@
"license": "SEE LICENSE IN <your-license-filename>", "license": "SEE LICENSE IN <your-license-filename>",
"repository": "<fill-your-repository-here>", "repository": "<fill-your-repository-here>",
"dependencies": { "dependencies": {
"@nativescript-community/ui-carto": "^1.8.18",
"@nativescript/core": "*", "@nativescript/core": "*",
"@nativescript/geolocation": "^9.0.0", "@nativescript/geolocation": "^9.0.0",
"@nativescript/localize": "^5.2.0" "@nativescript/localize": "^5.2.0"

View File

@ -1,7 +1,14 @@
import { EventData, Page } from '@nativescript/core'; import { EventData, Page } from '@nativescript/core';
import { HelloWorldModel } from './main-view-model'; import { HelloWorldModel } from './main-view-model';
import { CartoMap } from '@nativescript-community/ui-carto/ui';
import { MapModel } from './map-view-model';
export function navigatingTo(args: EventData) { export function navigatingTo(args: EventData) {
const page = <Page>args.object; const page = <Page>args.object;
page.bindingContext = new HelloWorldModel();
// There are issues with binding the events in the xml file due to naming
// conflicts with the map events (e.g. mapReady) so we use a reference to the map instead
const map = page.getViewById('cartoMap');
map.bindingContext = new MapModel(map as CartoMap);
page.bindingContext = new HelloWorldModel(map.bindingContext);
} }

View File

@ -1,22 +1,22 @@
<Page xmlns="http://schemas.nativescript.org/tns.xsd" navigatingTo="navigatingTo" class="page"> <Page xmlns="http://schemas.nativescript.org/tns.xsd" xmlns:map="@nativescript-community/ui-carto/ui" navigatingTo="navigatingTo" class="page">
<Page.actionBar> <Page.actionBar>
<ActionBar title="Blakus" icon="" class="action-bar"> <ActionBar title="Blakus" icon="" class="action-bar">
</ActionBar> </ActionBar>
</Page.actionBar> </Page.actionBar>
<StackLayout class="p-20"> <StackLayout>
<Label text="Tap the button" class="h1 text-center"/> <Label text="Tap the button"/>
<Button text="TAP" tap="{{ onTap }}" /> <Button text="TAP" tap="{{ onTap }}" />
<Button text="Location" tap="{{ onLocationTap }}" /> <Button text="Location" tap="{{ onLocationTap }}" />
<Label text="{{ message }}" class="h2 text-center" textWrap="true"/> <Label text="{{ message }}" textWrap="true"/>
<ListView items="{{ items }}" itemTap="{{ onItemTap }}"> <map:CartoMap id="cartoMap" row="0" zoom="15" width="100%" height="100%"></map:CartoMap>
<!-- <ListView items="{{ items }}" itemTap="{{ onItemTap }}">
<ListView.itemTemplate> <ListView.itemTemplate>
<!-- The item template can only have a single root element -->
<GridLayout padding="16" columns="20, *, *"> <GridLayout padding="16" columns="20, *, *">
<ContentView width="20" height="20" borderRadius="20" backgroundColor="#65adf1" /> <ContentView width="20" height="20" borderRadius="20" backgroundColor="#65adf1" />
<Label text="{{ title }}" col="1" textWrap="true" marginLeft="8" /> <Label text="{{ title }}" col="1" textWrap="true" marginLeft="8" />
<Label text="{{ L(`amenity.${amenity}`) }}" col="2" /> <Label text="{{ L(`amenity.${amenity}`) }}" col="2" />
</GridLayout> </GridLayout>
</ListView.itemTemplate> </ListView.itemTemplate>
</ListView> </ListView> -->
</StackLayout> </StackLayout>
</Page> </Page>

View File

@ -2,6 +2,7 @@ import { Observable, Http, type ItemEventData, ListView } from '@nativescript/co
import { DeviceLocation } from './location'; import { DeviceLocation } from './location';
import type { Location } from '@nativescript/geolocation'; import type { Location } from '@nativescript/geolocation';
import { localize } from '@nativescript/localize'; import { localize } from '@nativescript/localize';
import { MapModel } from './map-view-model';
type ReverseGeocodeResult = { type ReverseGeocodeResult = {
valsts: string; valsts: string;
@ -22,20 +23,15 @@ type ReverseGeocodeResult = {
} }
type BlakusItem = { type BlakusItem = {
id: string;
title: string; title: string;
amenity: string; amenity: string;
tags: Record<string, string>; tags: Record<string, string>;
affinity: number;
coords: {
lat: number;
lon: number;
} }
type OverpassResult = {
version: number;
generator: string;
osm3s: Record<string, string>;
elements: {
type: string,
id: number,
tags: Record<string, string>;
}[]
} }
async function aWait(timeout: number) { async function aWait(timeout: number) {
@ -43,38 +39,26 @@ async function aWait(timeout: number) {
} }
export class HelloWorldModel extends Observable { export class HelloWorldModel extends Observable {
private _counter: number; #items: BlakusItem[];
private _message: string; #locationServiceBaseURL = 'https://api.kartes.lv/v3/KVDM_mwwKi/'
private _items: BlakusItem[]; #blakusBaseURL = 'https://192.168.0.155:3000';
private _locationServiceBaseURL = 'https://api.kartes.lv/v3/KVDM_mwwKi/' #map: MapModel;
private _blakusBaseURL = 'https://192.168.0.155:3000';
constructor() { constructor(map: MapModel) {
super(); super();
this.#map = map;
// Initialize default values. this.#map.on('mapMoveEnded', () => {
this._counter = 42; this.getNearbyAmenities(this.#map.focusPosition);
this.updateMessage(); });
}
get message(): string {
return this._message;
}
set message(value: string) {
if (this._message !== value) {
this._message = value;
this.notifyPropertyChange('message', value);
}
} }
get items(): BlakusItem[] { get items(): BlakusItem[] {
return this._items || []; return this.#items || [];
} }
set items(value: BlakusItem[]) { set items(value: BlakusItem[]) {
if (this._items !== value) { if (this.#items !== value) {
this._items = value; this.#items = value;
this.notifyPropertyChange('items', value); this.notifyPropertyChange('items', value);
} }
} }
@ -84,9 +68,7 @@ export class HelloWorldModel extends Observable {
params.set('lat', latitude.toString()); params.set('lat', latitude.toString());
params.set('lon', longitude.toString()); params.set('lon', longitude.toString());
this.message = [latitude, longitude].join(', '); const url = new URL(`reverse_geocoding?${params}`, this.#locationServiceBaseURL).toString();
const url = new URL(`reverse_geocoding?${params}`, this._locationServiceBaseURL).toString();
const result = await Http.getJSON<ReverseGeocodeResult>(url); const result = await Http.getJSON<ReverseGeocodeResult>(url);
console.log(result); console.log(result);
@ -94,20 +76,33 @@ export class HelloWorldModel extends Observable {
} }
getBlakus(params = new URLSearchParams()) { getBlakus(params = new URLSearchParams()) {
const url = new URL(`blakus?${params.toString()}`, this._blakusBaseURL).toString(); const url = new URL(`blakus?${params.toString()}`, this.#blakusBaseURL).toString();
console.log('blakus query', url) return Http.getJSON<BlakusItem[]>(url)
return Http.getJSON<OverpassResult["elements"]>(url)
} }
async getOverpassAmenities({latitude, longitude}: Location) { async getNearbyAmenities({latitude, longitude}: Pick<Location, 'latitude' | 'longitude'>) {
const precision = 4; // limit uncached hits to API
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('lat', latitude.toString()); params.set('lat', latitude.toFixed(precision).toString());
params.set('lon', longitude.toString()); params.set('lon', longitude.toFixed(precision).toString());
const result = await this.getBlakus(params); this.items = await this.getBlakus(params);
console.log(result);
this.items = result.map(({ tags }) => ({ title: tags.name, amenity: tags.amenity, tags})) this.items.forEach(({ coords, title, amenity, affinity, id }) => {
// .filter(i => amenities.includes(i.amenity)); this.#map.addMarker({
id,
position: {
latitude: coords.lat,
longitude: coords.lon
},
detail: {
title,
description: localize(`amenity.${amenity}`),
affinity
}
})
});
this.#map.redrawMarkers();
} }
async postalPolygons(country = 'LV', code: string, mode: 'isolate' | 'collect' | 'union' = 'union') { async postalPolygons(country = 'LV', code: string, mode: 'isolate' | 'collect' | 'union' = 'union') {
@ -118,7 +113,7 @@ export class HelloWorldModel extends Observable {
params.set('union_mode', mode); params.set('union_mode', mode);
params.set('wgs84', ''); params.set('wgs84', '');
// params.set('wkt', ''); // params.set('wkt', '');
const url = new URL(`postal_codes?${params}`, this._locationServiceBaseURL).toString(); const url = new URL(`postal_codes?${params}`, this.#locationServiceBaseURL).toString();
const result = await Http.getJSON(url); const result = await Http.getJSON(url);
console.log(url); console.log(url);
@ -128,25 +123,19 @@ export class HelloWorldModel extends Observable {
async onLocationTap() { async onLocationTap() {
const location = await DeviceLocation.getDeviceLocation(); const location = await DeviceLocation.getDeviceLocation();
await this.getOverpassAmenities(location); const { latitude, longitude } = location;
if (this.#map) {
this.#map.setPosition({ longitude, latitude }, 500);
}
await this.getNearbyAmenities(location);
} }
onTap() { async onTap() {
this._counter--; await this.getNearbyAmenities(this.#map.focusPosition);
if (this._counter <= 0) {
this.updateMessage('Hoorraaay! You unlocked the NativeScript clicker achievement!');
} else {
this.updateMessage(`${this._counter} taps left`);
}
} }
onItemTap(args: ItemEventData) { // onItemTap(args: ItemEventData) {
const listView = args.object as ListView // const listView = args.object as ListView
console.log('Tapped item', listView.items[args.index]) // console.log('Tapped item', listView.items[args.index])
} // }
private updateMessage(message = "") {
this.message = message;
}
} }

View File

@ -0,0 +1,189 @@
import { CartoMap, MapEventData, MapMovedEvent, MapReadyEvent } from "@nativescript-community/ui-carto/ui";
import { Dialogs, Observable } from "@nativescript/core";
import { HTTPTileDataSource } from '@nativescript-community/ui-carto/datasources/http';
import { LocalVectorDataSource } from '@nativescript-community/ui-carto/datasources/vector';
import { RasterTileLayer } from '@nativescript-community/ui-carto/layers/raster';
import { VectorElementEventData, VectorLayer } from '@nativescript-community/ui-carto/layers/vector';
import { Marker, MarkerOptions, MarkerStyleBuilder } from '@nativescript-community/ui-carto/vectorelements/marker';
import { GenericMapPos, DefaultLatLonKeys } from "@nativescript-community/ui-carto/core";
function debounce<T extends (...args: any[]) => void>(func: T, delay: number): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return function(...args: Parameters<T>) {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
func(...args);
}, delay);
};
}
export class MapModel extends Observable {
#map: CartoMap;
#localDataSource: LocalVectorDataSource;
#vectorLayer: VectorLayer;
#markers: Marker<DefaultLatLonKeys>[] = [];
constructor(map: CartoMap) {
super();
this.#map = map;
this.#map.on(MapReadyEvent, this.onMapReady.bind(this));
this.#map.on(MapMovedEvent, this.onMapMoved.bind(this));
}
get options() {
return this.#map.getOptions();
}
get focusPosition() {
return this.#map.getFocusPos();
}
#positionInBounds(position: GenericMapPos<DefaultLatLonKeys>): boolean {
return this.#map.getMapBounds().contains(position);
}
#updateMarkerMetaData(marker: Marker<DefaultLatLonKeys>, data: Record<string, string>) {
marker.setProperty('metaData', { ...marker.metaData, ...data });
// console.log(marker.metaData);
}
#handleElementClick(data: VectorElementEventData<DefaultLatLonKeys>) {
// TODO: actually filter for marker
const marker = data.element as Marker<DefaultLatLonKeys>;
this.#updateMarkerMetaData(marker, { status: 'active' })
Dialogs.alert({
title: marker.metaData.title,
message: marker.metaData.description,
okButtonText: 'OK'
}).then(() => {
this.#updateMarkerMetaData(marker, { status: 'inactive' })
});
this.setPosition(data.elementPos, 200).then(() => {
if (this.#map.getZoom() < 17) {
this.#map.setZoom(17, data.elementPos, 500);
}
this.#map.setBearing(0, 500);
});
}
redrawMarkers() {
const visibleMarkers = this.#markers
.filter(marker => this.#positionInBounds(marker.position))
.sort((a, b) => parseInt(a.metaData.affinity) - parseInt(b.metaData.affinity))
.slice(0, 20);
// Hide markers that are not in bounds
this.#markers
.filter(marker => marker.metaData.status !== 'active' && !visibleMarkers.includes(marker))
.forEach(marker => {
marker.setProperty('visible', false);
this.#localDataSource.remove(marker);
this.#markers = this.#markers.filter(m => m !== marker);
});
// Add markers that are visible and not already added
visibleMarkers.forEach(marker => {
if (!marker.metaData.created_at) {
console.log('adding', marker.metaData);
this.#localDataSource.add(marker);
this.#updateMarkerMetaData(marker, { created_at: Date.now().toString() });
}
this.#updateMarkerMetaData(marker, { last_seen_at: Date.now().toString() });
marker.setProperty('visible', true);
});
}
addMarker({ id, position, detail }: { id: string, position: GenericMapPos, detail: Record<string, any> }) {
if (this.#markers.find(({ options : { metaData } }) => id === metaData.id)) return;
const markerStyleBuilder = new MarkerStyleBuilder({
size: 20,
color: '#0000ff',
clickSize: 50,
// anchor: [0.5, 1],
// image: 'res://marker',
});
const marker = new Marker({
projection: this.#map.projection,
position,
styleBuilder: markerStyleBuilder,
visible: false,
metaData: {
id,
title: detail.title,
description: detail.description,
affinity: detail.affinity,
status: 'inactive'
},
});
this.#markers.push(marker);
}
async setPosition(position: GenericMapPos, duration = 0) {
return new Promise((resolve, reject) => {
try {
this.#map.setFocusPos(position, duration);
setTimeout(() => {
resolve(this.#map.getFocusPos());
}, duration);
} catch (e) {
reject(e);
}
})
}
onMapMoved = debounce(({data}: MapEventData) => {
this.redrawMarkers();
this.notify({ eventName: 'mapMoveEnded', object: this });
}, 500)
onMapReady() {
this.setPosition({ latitude: 56.94964870, longitude: 24.10518640 });
const dataSource = new HTTPTileDataSource({
minZoom: 0,
maxZoom: 22,
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
httpHeaders: { 'User-Agent': 'Blakus' }
});
const rasterLayer = new RasterTileLayer({
dataSource,
zoomLevelBias: 1
});
this.#map.addLayer(rasterLayer);
// source for our stuff
this.#localDataSource = new LocalVectorDataSource({
projection: this.#map.projection
});
this.#vectorLayer = new VectorLayer({
dataSource: this.#localDataSource,
visibleZoomRange: [0, 24]
})
this.#map.addLayer(this.#vectorLayer);
this.#vectorLayer.setVectorElementEventListener({
onVectorElementClicked: this.#handleElementClick.bind(this)
}, this.#map.projection);
this.options.setRotatable(false);
}
}